Skip to content

Getting started

Install

pip install mcp-toolkit

From a clone, with the test dependencies:

git clone https://github.com/aseydaaksakal/mcp-toolkit
cd mcp-toolkit
pip install -e ".[dev]"
pytest

Python 3.10 or newer. The core has no runtime dependencies.

Your first server

Create server.py:

from mcp_toolkit import MCPServer, ToolError

server = MCPServer("hello", version="0.1.0")

ORDERS = {"ORD-1": "shipped", "ORD-2": "processing"}


@server.tool()
def lookup_order(order_id: str) -> dict:
    """Look up the status of an order.

    Args:
        order_id: Internal order identifier, e.g. "ORD-1".
    """
    if order_id not in ORDERS:
        raise ToolError(f"no order named {order_id!r}")
    return {"id": order_id, "status": ORDERS[order_id]}


if __name__ == "__main__":
    server.run()

Run it and drive it by hand. MCP over stdio is newline-delimited JSON, so a here-doc is a perfectly good client:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup_order","arguments":{"order_id":"ORD-1"}}}' \
  | python server.py

You should see three responses. The third contains:

{"content": [{"type": "text", "text": "{\n  \"id\": \"ORD-1\",\n  \"status\": \"shipped\"\n}"}], "isError": false}

Note what you did not write: no JSON Schema, no method table, no envelope handling. tools/list reports order_id as a required string with your docstring as its description, all read off the function.

Connecting a real client

Most desktop MCP clients take a command to run. For Claude Desktop, edit claude_desktop_config.json:

{
  "mcpServers": {
    "hello": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Use absolute paths — the client does not run from your shell's working directory. Restart the client afterwards; most read this file only at startup.

Debugging

Nothing happens. stdout is the protocol channel. A stray print() in your tool corrupts the stream and the client will usually go quiet rather than report an error. Log to stderr:

import sys
print("debug", file=sys.stderr)

AuditLog(stream=sys.stderr) gives you one JSON line per call for free.

The client sees no tools. Check your AccessPolicy. Denied tools are filtered out of tools/list deliberately, so a typo in an allow pattern looks exactly like a server with no tools. server.visible_tools() shows what a client would receive.

A tool is never called. Read the schema the model is reading:

import json
print(json.dumps(server.tools["lookup_order"].input_schema, indent=2))

If the description is vague or a parameter has no documentation, the model has to guess. Fill in the docstring Args: block, or pass description= and schema= to the decorator to override what is generated.

Next

  • tools.md — how schemas are generated and how to override them
  • adapters.md — connect a database, an HTTP API or a directory
  • security.md — what to lock down before this touches production