Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,12 @@ repos:
- spacy
- tokenizers>=0.21,<0.23
- posthog==7.12.0
# Ceiling matches requirements/base.txt's runtime pin. mcp>=1.0.0
# (no ceiling) let this hook resolve mcp 2.x, whose SDK renamed
# fields/methods that opencontractserver/mcp/server.py (1.x API)
# doesn't have -- turning main red with no commit touching mcp.
- mcp>=1.28.1,<2
# Range matches requirements/base.txt's runtime pin exactly.
# opencontractserver/mcp/server.py targets the python-sdk 2.x
# API (on_*= handler kwargs, snake_case type fields); a hook env
# resolving a different major than the runtime turns main red
# with no commit touching mcp. Bump both pins together.
- mcp>=2.2.0,<3
- argon2-cffi==25.1.0
- cryptography==46.0.7
- pyjwt==2.12.1
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2334-mcp-sdk-2.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Migrated the MCP server to `mcp` (python-sdk) 2.x** (`requirements/base.txt`: `mcp>=2.2.0,<3`; #2334). python-sdk 2.0 removed the decorator-based handler registration on `mcp.server.Server` (`@server.list_tools()`, `server.call_tool()(...)`) in favour of `on_*=` constructor kwargs whose handlers take `(ctx, params)` and return typed result models. `opencontractserver/mcp/server.py` now builds both the global server (`create_mcp_server`) and the per-corpus scoped server (`create_scoped_mcp_server`) through a shared set of adapters (`_build_on_call_tool`, `_build_on_list_tools`, `_build_on_list_resource_templates`, `_on_read_resource`) so argument validation, error wrapping and resource serialisation live in one place. Behaviour the 1.x decorators used to provide is preserved explicitly: tool arguments are validated against the advertised `inputSchema` (jsonschema; mistyped arguments return an `isError` result, `"Input validation error: ..."`, and — new — still consume the per-tool rate-limit bucket and record an `InputValidationError` telemetry event via `_reject_malformed_arguments`, so malformed calls cannot bypass either), and any exception escaping a dispatcher (unknown tool, rate limit) still becomes an `isError` result rather than a transport error. The global tool/template catalogues moved out of the factory closure into `get_tool_definitions()` / `get_resource_template_definitions()` (mirroring the scoped equivalents). Wire-level changes: `resources/read` payloads are now stamped `application/json` (`MCP_RESOURCE_MIME_TYPE`, matching the advertised templates; 1.x's deprecated str-return path stamped `text/plain`), and a resource read that fails on the caller's side (unrecognised URI, invisible/missing corpus or document) returns JSON-RPC `INVALID_PARAMS` with the message instead of a bare internal error. `jsonschema` is now an explicit pin in `requirements/base.txt` (it was only transitive via `mcp`). SDK types now use their 2.x snake_case fields (`input_schema`, `uri_template`, `mime_type`; URIs are plain `str`). Tests: the SDK-level tests drive both servers through `mcp.client.Client` in-memory sessions (`MCPSdkClientRoundTripTest`) and a real stateless Streamable HTTP JSON-RPC request through `StreamableHTTPSessionManager`, replacing the removed `server.request_handlers[...]` seam.
27 changes: 27 additions & 0 deletions docs/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,33 @@ python -m opencontractserver.mcp.server
- `config/asgi.py` - HTTP routing (`/mcp/*` and `/sse/*` → MCP app)
- `compose/production/traefik/traefik.yml` - Production routing (Traefik)

### SDK integration

Both servers are `mcp.server.Server` instances from the official
[python-sdk](https://github.com/modelcontextprotocol/python-sdk) **2.x**
(`requirements/base.txt` pins `mcp>=2.2.0,<3`). Request handlers are passed as
`on_*=` constructor kwargs and return typed result models; the 1.x decorator
API no longer exists. The adapters in `server.py` (section `MCP SDK HANDLER
ADAPTERS`: `_build_on_call_tool`, `_build_on_list_tools`,
`_build_on_list_resource_templates`, `_on_read_resource`) are the only code
that touches that SDK surface — `create_mcp_server` and
`create_scoped_mcp_server` compose them over the transport-agnostic
dispatchers (`call_tool_handler`, `read_resource_handler`, the scoped
`call_tool` closure), so a future SDK change is a one-place edit.

Contract preserved from 1.x and pinned by `MCPSdkClientRoundTripTest`
(`opencontractserver/mcp/tests/test_mcp.py`):

- Tool arguments are validated against the advertised `inputSchema`; a
mismatch returns an `isError` result (`Input validation error: ...`) and
still consumes the per-tool rate-limit bucket and records telemetry.
- Exceptions escaping a dispatcher (unknown tool, rate limit) become `isError`
results, never transport errors. Django `PermissionDenied` /
`ValidationError` / `DoesNotExist` are structured `{"error": ...}` payloads.
- `resources/read` returns `application/json` text contents; a URI the caller
cannot resolve (unknown pattern, invisible corpus) is a JSON-RPC
`INVALID_PARAMS` error carrying the message.

---

## Authentication
Expand Down
5 changes: 5 additions & 0 deletions opencontractserver/constants/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@
# over-fetching a small ``limit`` could be consumed entirely by duplicates.
MCP_SEARCH_CANDIDATE_MULTIPLIER: int = 3 # candidate fetch = limit * this
MCP_SEARCH_CANDIDATE_MAX: int = 150 # absolute cap on candidate fetch per half

# MIME type stamped on every ``resources/read`` payload. All MCP resources
# (corpus, document, annotation, thread) serialise to JSON, matching the
# ``mime_type`` advertised on their ``ResourceTemplate`` entries.
MCP_RESOURCE_MIME_TYPE: str = "application/json"
Loading
Loading