MCP Protocol Base

The Model Context Protocol, over Streamable HTTP, in Django: what every MCP server of the app shares.

MCPServerBase answers the JSON-RPC messages of one POST request, and MCPToolBase is the base class of a tool. A server names itself, describes itself, gives its instructions, and holds its tools. Everything else, the initialize handshake, ping, tools/list and tools/call, JSON-RPC batches and errors, is here. The app’s servers:

The server speaks the initialize handshake of every protocol version that the MCP Python SDK negotiates (mcp_types.version.HANDSHAKE_PROTOCOL_VERSIONS):

  • initialize: the server’s identity, its tools capability and its instructions.

  • notifications/initialized and every other notification: accepted, and ignored.

  • ping: an empty result.

  • tools/list: the server’s tools.

  • tools/call: runs a tool. A tool’s own errors, e.g. a file that does not exist, are returned as a result with isError set, so that the LLM can correct its call.

Why not the MCP SDK’s server. The SDK’s server runs as an ASGI application, with an anyio task group for its lifetime, and calls its tools as coroutines. Smarter’s views and ORM are synchronous, and the platform runs under both the Django development server and uvicorn. Every message is answered from the platform’s own data, with no state between requests, so a server is a plain Django view that uses the SDK’s own Pydantic models, mcp_types, to validate the requests and to build the responses.

Stateless. A server answers every POST with application/json, and does not issue an Mcp-Session-Id. Clients therefore open no GET stream, and send no DELETE, and any of the platform’s pods can answer any request.

Signals. Every notification and request that a server accepts sends mcpserver_called, and every tool call sends mcpserver_tool_called. The app’s receivers log them.

Note

Experimental. The MCPServer was designed and coded by Claude Code (Anthropic’s Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will be documented.

smarter.apps.mcpserver.protocol.base.JsonRpcResponse = mcp_types.jsonrpc.JSONRPCResponse | mcp_types.jsonrpc.JSONRPCError

a response with a result, or an error.

Type:

The answer to one JSON-RPC request

smarter.apps.mcpserver.protocol.base.MAX_BATCH_SIZE = 20

The maximum number of messages in a JSON-RPC batch, which protocol 2025-03-26 allows.

A larger batch, or an empty one, is refused with one INVALID_REQUEST error, so that one POST cannot make a server run an unbounded number of tool calls.

class smarter.apps.mcpserver.protocol.base.MCPServerBase[source]

Bases: ABC

An MCP server: answers the JSON-RPC messages of one POST request with its tools.

A server lives for one request: the view creates it, calls handle() with the request’s body, and returns what it answers. A subclass sets tools in its constructor, and implements name, server_info() and instructions().

Methods:

  • handle(): answers a request’s body, one message or a batch.

  • handle_message(): answers one message, with a JSON-RPC response or error.

  • dispatch(): runs one JSON-RPC method: initialize, ping, tools/list or tools/call.

  • error() and dump(): build a JSON-RPC error, and serialize a response. The views also use them, for errors that they answer before a server is created.

call_tool(params)[source]

Run a tool.

A SmarterMCPServerToolError that the tool raises is returned as the result’s text, with is_error set, so that the LLM can read it and correct its call. Sends mcpserver_tool_called after every call, whether or not it failed.

Parameters:

params (CallToolRequestParams) – The tool’s name and arguments.

Return type:

CallToolResult

Returns:

The tool’s result, as text. A tool error is a result with is_error set.

Raises:

LookupError – If there is no such tool.

dispatch(method, params)[source]

Run one JSON-RPC method.

Parameters:
  • method (str) – The method, e.g. tools/call.

  • params (dict[str, Any]) – Its params, or an empty dict.

Return type:

Result

Returns:

Its result.

Raises:
  • LookupError – If the server does not implement the method, or the tool does not exist.

  • ValidationError – If the params are not valid.

static dump(response)[source]

Return a response as JSON.

Fields that are None are left out of a result, but an error keeps its id, which JSON-RPC requires, even when it is null.

Parameters:

response (JSONRPCResponse | JSONRPCError) – The response or error.

Return type:

TypeAliasType

Returns:

The response as a JSON-serializable dict, with the protocol’s camelCase names.

static error(request_id, code, message)[source]

Return a JSON-RPC error response.

Parameters:
  • request_id (int | str | None) – The id of the request that failed, or None if it could not be read.

  • code (int) – The JSON-RPC error code, e.g. mcp_types.INVALID_REQUEST.

  • message (str) – What went wrong, for the client.

Return type:

JSONRPCError

Returns:

The error response. Serialize it with dump().

property formatted_class_name: str

The module, class and server name, formatted for the start of a log message.

handle(payload)[source]

Answer the JSON-RPC message, or batch of messages, of one POST request.

A batch is a list of 1 to MAX_BATCH_SIZE messages, and is answered with a list of the responses of the messages that need one, in their order. A batch of notifications only needs no response.

Parameters:

payload (TypeAliasType) – The request body, parsed from JSON.

Return type:

TypeAliasType | None

Returns:

The response body, or None if no message needs a response, e.g. a notification.

handle_message(message)[source]

Answer one JSON-RPC message.

Sends mcpserver_called for each valid notification and request. An exception that a method raises is answered with a JSON-RPC error: invalid params with INVALID_PARAMS, an unknown method or tool with METHOD_NOT_FOUND, and any other exception with INTERNAL_ERROR, which is logged with its traceback, but whose message is not returned, so that internal details never reach the client.

Parameters:

message (TypeAliasType) – The message.

Return type:

JSONRPCResponse | JSONRPCError | None

Returns:

The response, or None for a notification, or for a response from the client.

handle_notification(message)[source]

Accept a JSON-RPC notification, a message without an id, and ignore it.

The server keeps no state between requests, so a notification, e.g. notifications/initialized, changes nothing. A valid notification sends mcpserver_called, so that it is logged, and one that is not valid is dropped, because JSON-RPC never answers a notification, not even with an error.

Parameters:

message (dict[str, Any]) – The notification, a dict with a method and no id.

Return type:

None

Returns:

None: a notification has no response.

initialize(params)[source]

Answer the initialize handshake.

The server offers only the tools capability, and its list of tools never changes during a session, so listChanged is false.

Parameters:

params (InitializeRequestParams) – The client’s offer: its protocol version, capabilities and identity.

Return type:

InitializeResult

Returns:

The client’s protocol version, if this server speaks it, and otherwise the latest that it speaks, which the client may refuse.

abstractmethod instructions()[source]

Return the server’s instructions, which MCP clients add to the LLM’s system prompt.

Return type:

str

Returns:

Plain text that tells the LLM what the server is for, and when to call each tool.

abstract property name: str

The server’s name, e.g. smarter-project.

abstractmethod server_info()[source]

Return the server’s identity, for the initialize result.

Return type:

Implementation

Returns:

The server’s name, title, version, description and website.

tools: dict[str, MCPToolBase]

The server’s tools, by their name, in the order that tools/list lists them.

class smarter.apps.mcpserver.protocol.base.MCPToolBase[source]

Bases: ABC

A tool of an MCP server: a name, a description, and a Pydantic model of its arguments.

The tool’s MCP inputSchema is generated from arguments_model, and call() returns its result as Markdown text for the LLM. A subclass sets the class attributes, and implements call(). The two families of tools are SmarterProjectTool, of the public Smarter Project MCP server, and SmarterOperationsTool, of the staff-only Smarter Operations MCP server.

arguments_model: ClassVar[type[BaseModel]]

The Pydantic model of the tool’s arguments, which validates them and generates the inputSchema.

abstractmethod call(arguments)[source]

Validate a tool call’s arguments, and run the tool.

Parameters:

arguments (dict[str, Any] | None) – The tool call’s arguments, as the client sent them, or None.

Return type:

str

Returns:

The result, as Markdown.

Raises:

SmarterMCPServerToolError – If the arguments are not valid, or the tool fails. The server returns the error’s message to the LLM as a result with isError set.

definition()[source]

Return the tool, as tools/list lists it.

Its annotations are hints for the MCP client: a read-only tool is also idempotent, and no tool of this app destroys anything.

Return type:

Tool

Returns:

The tool’s name, title, description, inputSchema and annotations.

description: ClassVar[str]

What the tool does, and when to call it.

The LLM reads it to decide which tool to call.

input_schema()[source]

Return the tool’s MCP inputSchema.

The schema is generated from arguments_model, and simplified with simplify_schema(). MCP requires a schema of type object, and additionalProperties: false tells the LLM that it may not invent arguments.

Return type:

dict[str, Any]

Returns:

A JSON schema of type object, without additional properties.

name: ClassVar[str]

The tool’s name, which the LLM calls it by, e.g. search_logs.

open_world: ClassVar[bool] = False

Whether the tool reaches beyond the platform’s own data, e.g. to GitHub.

read_only: ClassVar[bool] = True

Whether the tool only reads.

A tool that writes is annotated so, with readOnlyHint false, so that an MCP client can ask its user before it calls it.

title: ClassVar[str]

The tool’s human-readable name, which an MCP client may show, e.g. Search pod logs.

smarter.apps.mcpserver.protocol.base.SERVER_VERSION = '9999.9999.9999.dev9999'

the platform’s, e.g. 0.41.0.

It is the version of the server’s identity in the initialize result, so that a client can tell which release of the platform answered it.

Type:

Every server’s version

smarter.apps.mcpserver.protocol.base.invalid_arguments(tool_name, error)[source]

Return the tool error for arguments that are not valid, naming each problem, for the LLM to correct.

The error is returned to the LLM as a tool result with isError set, rather than as a JSON-RPC error, so that it can read what was wrong and call the tool again. Each problem is the argument’s dotted path, or arguments for the arguments as a whole, then Pydantic’s message, e.g. invalid arguments for search_logs: limit: Input should be less than or equal to 200.

Parameters:
  • tool_name (str) – The tool’s name, e.g. search_logs.

  • error (ValidationError) – Pydantic’s validation error of the arguments.

Return type:

SmarterMCPServerToolError

Returns:

The error, for the caller to raise.

smarter.apps.mcpserver.protocol.base.simplify_schema(schema)[source]

Simplify a Pydantic JSON schema for an LLM.

Pydantic’s schema carries what helps a form, not an LLM: a title for the model and for each field, the model’s docstring as its description, and "default": null for each optional field. They cost tokens in every tools/list result, and the docstring is written for developers, so they are removed. An optional field’s anyOf: [<type>, {"type": "null"}] becomes its type, which every MCP client understands.

Parameters:

schema (dict[str, Any]) – The schema, from model_json_schema(). It is not changed: the function works on a deep copy.

Return type:

dict[str, Any]

Returns:

The simplified schema.