Skip to main content
Glama
README.md
# mcpkit — Build and Test MCP Servers

[![CI](https://github.com/mehuljariwala/mcp-server-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/mehuljariwala/mcp-server-toolkit/actions/workflows/ci.yml)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![MCP 2026-07-28](https://img.shields.io/badge/MCP-2026--07--28-8A2BE2.svg)](https://modelcontextprotocol.io/specification/)
[![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](pyproject.toml)

A Model Context Protocol server framework with the thing MCP tooling mostly lacks: **a way to test the server**.

```bash
uv venv && uv pip install -e ".[dev]" && pytest    # 42 tests, zero deps
python examples/filesystem_server.py               # conformance report
```

```python
from mcpkit import Server, TestClient

server = Server("my-server")

@server.tool(description="Add two numbers.", read_only=True)
def add(a: int, b: int) -> int:
    return a + b

client = TestClient(server)
assert client.call_text("add", a=2, b=3) == "5"
```

---

## Testing MCP servers

The usual approach is launching the server as a subprocess and driving it with a real client: slow, awkward in CI, terrible failure messages.

`TestClient` speaks the protocol **in-process** — real JSON-RPC messages, real handshake sequencing, real error shapes — but as ordinary function calls with ordinary assertions. Every message round-trips through `json.dumps`/`loads`, so anything unserialisable fails in a test rather than against a real client.

## The conformance checker

`conformance_report()` checks what actually breaks interoperability — the things server authors never test because their one client happens to tolerate them:

```
✓ initialize returns a protocolVersion
✓ initialize returns serverInfo.name
✓ initialize returns capabilities
✓ notifications receive no response
✓ requests before initialize are rejected
✓ unknown methods return -32601
✓ tools/list returns a list
✓ every tool has name, description and inputSchema
✓ every inputSchema is an object schema
✓ no tool has an empty description
✓ calling an unknown tool returns -32602
✓ every response carries jsonrpc 2.0
✓ every response echoes its request id
✓ no response has both result and error

14/14 conformance checks passed
```

**"Notifications receive no response"** is the one that bites hardest. `notifications/initialized` has no `id`, so it must not be answered — reply anyway and a strict client hangs up, with no useful error on either side.

**"No tool has an empty description"** catches something valid-but-useless: a tool a model cannot decide when to call.

## Failing tools are results, not protocol errors

```python
result = client.call_tool("divide", a=1, b=0)
result["isError"]                    # True
result["content"][0]["text"]         # "ZeroDivisionError: division by zero"
```

A tool that raises returns `isError: true` **with the message**, so the model sees what went wrong and can correct. A JSON-RPC error would make the call vanish from the model's view entirely.

An *unknown* tool is a protocol error — that's a client bug, not something the model can fix.

## Schemas come from signatures

```python
@server.tool(description="Greet someone.", name="who to greet")
def greet(name: str, formal: bool = False) -> str: ...
```
```json
{"type": "object",
 "properties": {"name": {"type": "string", "description": "who to greet"},
                "formal": {"type": "boolean"}},
 "required": ["name"]}
```

Uses `inspect.signature(fn, eval_str=True)`. Without `eval_str`, code with `from __future__ import annotations` gets annotations as *strings*, every parameter silently degrades to `"string"`, and the client sends the wrong types with no error anywhere.

## A bug worth keeping

`call_text(name, **arguments)` collided with a tool argument called `name` — and `name` is one of the most common tool arguments there is:

```
TypeError: TestClient.call_text() got multiple values for argument 'name'
```

Fixed with positional-only parameters, pinned by `TestArgumentCollisions`. (The same trap caught me in [prompt-registry](https://github.com/mehuljariwala/prompt-registry); it's worth knowing by name.)

## Example server

`examples/filesystem_server.py` is a read-only filesystem server: `list_files`, `read_file`, `count`, one resource, one prompt — and a sandbox that resolves paths **before** checking containment, so `../../../etc/passwd` and symlinks both get rejected.

```bash
python examples/filesystem_server.py --serve   # over stdio, for a real client
```

## Transport

Line-delimited JSON over stdio. Anything the server logs must go to **stderr** — stdout is the transport, and a stray `print()` there corrupts the stream in a way that is genuinely painful to diagnose.

## License

MIT — see [LICENSE](LICENSE).

---

Built by [Mehul Jariwala](https://github.com/mehuljariwala) · [LinkedIn](https://www.linkedin.com/in/mehul-jariwala-352a01132/)