← Back to Journal
· 4 min read

Write a Zero-Dependency MCP Server in 60 Lines of Python and Plug It Into DeepSeek Harness

You don't need the official SDK to give DSH your own tools. MCP's stdio transport is just line-delimited JSON-RPC — this guide builds a fully handshake-compliant server in 60 lines of pure-stdlib Python, smoke-tests it through a pipe with real output, registers it in DSH via one cordis.patch.yml block, and covers the streamable-http variant for remote tool hubs.

TL;DR

TL;DR — An MCP server is not a framework, it's a contract: line-delimited JSON-RPC 2.0 over stdin/stdout. Answer initialize, tools/list, and tools/call, stay silent on notifications, and you're done — 60 lines of pure standard-library Python, verified with a real pipe smoke test. Then one YAML block in cordis.patch.yml makes the tool a native model tool in DSH. Remote hubs use the same shape with transport: streamable-http and a url.

A month of Bing query data told us something interesting: a small cluster of searches like dsh mcp config and deepseek harness custom mcp tutorial all landing on our August DSH setup guide. That article covered wiring an existing MCP server in. These searchers were asking for the layer below: how do I turn my own scripts into tools DSH can call?

The answer is cheaper than expected. An MCP server is not a framework — it’s a contract, and the contract lives on your process’s stdin/stdout. No SDK, no pip installs.

MCP, reduced to its minimum

Over stdio transport, the client (DSH’s @deepseek-ai/dsh-mcp-client plugin) spawns your process, then both sides exchange JSON-RPC 2.0 messages, one per line. A full session is four kinds of traffic:

  • initialize request → you return the protocol version, capabilities, server info
  • notifications/initialized notification → no reply (JSON-RPC notifications carry no id and must not be answered)
  • tools/list request → you return the tool list: name, description, JSON Schema per tool
  • tools/call request → you get the tool name and arguments, execute, return content

That’s all. Once you see this layer, “write an MCP server” degrades into “write a loop that reads lines and dispatches on method.”

The 60-line implementation

Here is the minimal version we ran on this machine — pure standard library, one demo tool:

#!/usr/bin/env python3
"""Minimal MCP stdio server - zero dependencies, pure stdlib."""
import json, sys

TOOLS = [{
    "name": "char_count",
    "description": "Count characters in a text string",
    "inputSchema": {"type": "object",
                    "properties": {"text": {"type": "string"}},
                    "required": ["text"]},
}]

def send(msg):
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    msg = json.loads(line)
    if "id" not in msg:  # notification, no response
        continue
    m = msg.get("method")
    if m == "initialize":
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {
            "protocolVersion": msg["params"].get("protocolVersion", "2025-06-18"),
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "mini-mcp", "version": "0.1.0"}}})
    elif m == "tools/list":
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {"tools": TOOLS}})
    elif m == "tools/call":
        text = msg["params"]["arguments"]["text"]
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {
            "content": [{"type": "text", "text": str(len(text))}],
            "isError": False}})
    else:
        send({"jsonrpc": "2.0", "id": msg["id"],
              "error": {"code": -32601, "message": f"method not found: {m}"}})

Three details deserve a pause:

  • send() flushes every write. stdout is block-buffered; unflushed writes mean the client waits forever. This is the most common silent death of hand-rolled stdio servers.
  • Notifications hit continue. Replying to a message that has no id isn’t politeness, it’s a protocol violation.
  • Echo the client’s protocolVersion. The handshake is version negotiation — accept what the client sends rather than inventing your own.

Smoke test: no DSH required

Because MCP is a text protocol, you can test the whole flow through a pipe. Feed it four messages in order:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"handtest","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"char_count","arguments":{"text":"hello mcp"}}}' \
 | python3 server.py

Real output from our machine:

{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "mini-mcp", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "char_count", "description": "Count characters in a text string", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}]}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "9"}], "isError": false}}

Three responses: handshake passes, tool declaration passes, the call returns "9". At this point your server is a legitimate MCP server — for every client, not just DSH.

Wiring it into DSH: one YAML block

Edit ~/.dsh/profiles/web/cordis.patch.yml and add an insert entry:

- insert:
    - id: mcp-mini
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: mini
        transport: stdio
        command: python3
        args: ['/path/to/server.py']
        toolCallTimeoutMs: 60000

Save and it’s live — DSH’s HMR disconnects and reconnects, and the tool shows up in the session as mcp__mini__char_count, the same mcp__<server>__<tool> naming convention Claude Code uses.

To check a composed profile without booting the web UI, run --dump-config. Here’s real output from dsh --profile headless --patch ./patch.yml --dump-config on our machine (excerpt):

# == /tmp/mini_mcp/patch.yml
- id: mcp-mini
  name: '@deepseek-ai/dsh-mcp-client'
  config:
    serverName: mini
    transport: stdio
    command: python3
    args:
      - /tmp/mini_mcp/server.py
    toolCallTimeoutMs: 60000

Note the layering: base bundles, the user layer, then --patch overlays, each annotated in the dump. Your MCP config always lives in the top layer — upgrading DSH never touches it.

Remote tool hubs: streamable-http

When the tools aren’t local, switch transport to streamable-http and replace command/args with a url. Our own profile runs both kinds side by side:

- insert:
    - id: mcp-remote-hub
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: hub
        transport: streamable-http
        url: https://mcp.jask.dev/mcp

Local stdio suits your own scripts and internal data; streamable-http suits shared team tool hubs. Both coexist in the same patch file, one insert block each.

Four pitfalls we actually hit

  • The default timeout is 60 seconds. Batch tools (audits, crawls) routinely exceed it — raise toolCallTimeoutMs. It’s per tool call, not per session.
  • stdio children don’t inherit your shell’s cwd. Relative paths (.env files, data caches) resolve to nowhere; set cwd in the config or use absolute paths in code.
  • MISSING_CREDENTIAL has nothing to do with MCP. That error is about the model API key (~/.dsh/settings.yaml, written by the web UI’s Models page). Don’t debug it in your MCP config.
  • Never print debug output to stdout. stdout is the protocol channel; any stray line corrupts the JSON stream. Debug goes to stderr.

From demo to production

Swap char_count for your real tools and this is production shape. Our open-source SEO toolkit registers 22 tools the same way — keyword_research, kd, geo_score, and friends, pure standard library: the repo is on GitHub, and python3 -m zens_ink.mcp starts the server directly.

For DSH’s own install, web UI, and profile system, see the hands-on setup guide; full tool reference lives in the docs. And if you’re assembling an agent toolchain, see how we scrape SERP data on free API tiers — those same scripts now run behind an MCP wrapper.

FAQ

Can I write an MCP server without the official SDK?

Yes. MCP's stdio transport is newline-delimited JSON-RPC 2.0 over the process's stdin/stdout. A server only needs to answer three request methods — initialize (handshake, echo the protocol version), tools/list (declare tools with JSON Schema), and tools/call (execute and return content) — and must not reply to notifications, which carry no id. Python's json and sys modules are enough; the whole thing fits in 60 lines.

How do I add my own MCP server to DSH?

Edit ~/.dsh/profiles/web/cordis.patch.yml and insert an entry with name @deepseek-ai/dsh-mcp-client. Its config takes serverName, transport: stdio, command, args, and toolCallTimeoutMs. Saving the file is enough — HMR reconnects without a restart, and the tool appears as mcp__<server>__<tool> in the session.

My MCP server starts but hangs. What's wrong?

Check the handshake first: initialize must return a result containing protocolVersion, and notifications like notifications/initialized have no id and must get no reply — just skip them. Second, make sure every write flushes; block-buffered stdout means the client waits forever. Third, the default per-call timeout is 60 seconds — raise toolCallTimeoutMs for batch tools that run longer.

How do I connect a remote MCP tool hub instead?

Set transport: streamable-http and replace command/args with a single url field. Registration works identically. Local stdio suits your own scripts and internal data; streamable-http suits shared team tool hubs — both can coexist in the same profile, one insert block each.

Want to run this analysis on your own site?

ZensInk Pro automates this pipeline. One command, from seed keywords to content plan.

Get Pro →