mcpkit
README.md
# mcpkit — Build and Test MCP Servers
[](https://github.com/mehuljariwala/mcp-server-toolkit/actions/workflows/ci.yml)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io/specification/)
[](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/)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues