mcp - Model Context Protocol server and client
Import with import "mcp.j" as mcp;. A Model Context Protocol implementation: a server that exposes tools / resources / prompts to an LLM host, and an HTTP client that calls a remote MCP server. MCP is JSON-RPC 2.0 on the wire, so the client is a thin layer over jsonrpc (which is over http + json) and the server is a purpose-built router. Because it uses jsonrpc / http, this module needs the default jennifer binary.
This module targets the stateless protocol only - no SSE, no sessions. The stdio server (the primary transport), the HTTP client, and the stdio subprocess client are all implemented (see Scope).
import "mcp.j" as mcp;
use json;
func echo(args as json.Value) {
return json.asString($args, "/text");
}
def sch as json.Value init mcp.property(mcp.schema(), "text", "string", "text to echo", true);
def srv as mcp.Server init mcp.addTool(mcp.server("demo", "1.0.0"), "echo", "echo text", $sch, echo);
# answer one request (transport-agnostic):
def reply as string init mcp.handle($srv, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}");Runnable: examples/modules/mcp_demo.j.
Building a server
A Server is an identity plus registries of tools, resources, and prompts. Build it value-semantically: each builder returns a fresh Server.
def struct mcp.Server {
name as string, version as string,
tools as list of mcp.Tool,
resources as list of mcp.Resource,
prompts as list of mcp.Prompt
};| Call | Returns | |
|---|---|---|
mcp.server(name, version) | Server | an empty server with the given identity |
mcp.addTool(server, name, description, inputSchema, handler) | Server | register a tool; inputSchema is a json.Value JSON Schema, handler a func value |
mcp.addResource(server, uri, name, description, mimeType, handler) | Server | register a resource keyed by uri |
mcp.addPrompt(server, name, description, arguments, handler) | Server | register a prompt template; arguments is a list of PromptArg (declared to the host, may be []) |
mcp.promptArg(name, description, required) | PromptArg | one prompt-argument declaration for addPrompt |
Each item's handler is a func value - a top-level method in the program that imported the module, passed by its bare name and called through its home interpreter, so it resolves its own imports and builds its json.Value result in the entry program's context (the same mechanism the web module uses):
- a tool handler is
func NAME(arguments as json.Value)and returns ajson.Valueor a scalar (a string is used as the text content verbatim; anything else is JSON-encoded into the text content); - a resource handler is
func NAME(uri as json.Value)and returns the resource's text; - a prompt handler is
func NAME(arguments as json.Value)and returns the prompt messages as ajson.Valuearray. Each message is{role, content}wherecontentis a content block{"type": "text", "text": ...}- not a bare string (MCP requires the block form, and a host rejects a plain string).
A prompt's arguments (built with promptArg) are surfaced by prompts/list, so a host can collect them before calling prompts/get. Pass [] for a prompt that takes none.
Declaring a tool's input schema
A tool declares a JSON-Schema object describing its arguments. Build it with the two schema helpers (a small convenience over the json write API):
| Call | Returns | |
|---|---|---|
mcp.schema() | json.Value | a {"type":"object","properties":{},"required":[]} skeleton |
mcp.property(schema, name, jsonType, description, required) | json.Value | add properties/<name> = {type, description}; append name to required when required is true |
def sch as json.Value init mcp.property(
mcp.property(mcp.schema(), "a", "integer", "first addend", true),
"b", "integer", "second addend", true);Serving
mcp.handle(server, requestBody) turns one raw JSON-RPC request into a reply body - it routes the whole stateless MCP surface and leaves the transport to you. A notification (a request with no id, or any notifications/ method) owes no reply and returns "".
| Call | Returns | |
|---|---|---|
mcp.handle(server, requestBody) | string | the reply JSON, or "" when none is owed |
mcp.serveStdio(server) | run the stdio transport: read newline-delimited JSON-RPC from the program's own stdin, dispatch each through handle, write each non-empty reply to stdout, until EOF |
serveStdio is the primary MCP transport - an LLM host launches the program and speaks JSON-RPC over its stdin/stdout:
import "mcp.j" as mcp;
# ... build $srv ...
mcp.serveStdio($srv); # blocks, reading stdin until EOFFor HTTP, wire handle to httpd yourself:
def req as httpd.Request init httpd.accept($hsrv);
def body as string init convert.stringFromBytes(httpd.body($req), "utf-8");
httpd.respond($req, 200, mcp.handle($srv, $body));Routed methods
| Method | Result |
|---|---|
initialize | {protocolVersion, capabilities: {tools, resources, prompts}, serverInfo: {name, version}} |
ping | {} |
notifications/* | no reply ("") |
tools/list | {tools: [{name, description, inputSchema}]} |
tools/call | {content: [{type: "text", text}], isError} (params {name, arguments}) |
resources/list | {resources: [{uri, name, description, mimeType}]} |
resources/read | {contents: [{uri, mimeType, text}]} (params {uri}) |
prompts/list | {prompts: [{name, description, arguments?}]} (arguments present when the prompt declares any) |
prompts/get | {description, messages} (params {name, arguments}) |
The protocol version reported is 2025-06-18. An unknown protocol method is a -32601 (method not found) reply; an unparseable body is -32700 (parse error); an unknown resource / prompt is -32602 (invalid params).
Security - dispatch is an allow-list. Unlike
jsonrpc'shandle, which dispatches a request'smethodto any top-levelfuncof that name,mcp.handleonly ever invokes a handler registered in the server:
- A
tools/calllooks the tool up by name in the registry; an unregistered name is a tool-result error (isError: true), never a dispatch to an arbitrary top-level method. The same holds for resources (byuri) and prompts (byname).- A tool handler that throws yields a tool-result error with a generic message; the thrown message stays server-side and is not put on the wire, so you can raise detailed errors without leaking internals.
- Authenticate at the transport (check a header / token before calling
handle); the module does no authentication of its own.
Client (HTTP)
mcp.connect binds a client to a remote MCP endpoint over HTTP (it wraps a jsonrpc.Client). Every call POSTs a JSON-RPC request; any failure
- a JSON-RPC error reply, a transport error, or a malformed reply - throws a
catchable Error{kind: "jsonrpc"}.
def struct mcp.Client { rpc as jsonrpc.Client };| Call | Returns | |
|---|---|---|
mcp.connect(endpoint) | Client | bind to an MCP endpoint URL (HTTP) |
mcp.connectWith(endpoint, headers) | Client | HTTP, with extra request headers (auth, ...) |
mcp.connectStdio(argv) | Client | launch an MCP server subprocess and talk to it over stdio |
mcp.initialize(client) | json.Value | run the initialize handshake (and send the required notifications/initialized on success); returns the server's result |
mcp.listTools(client) | json.Value | the tools array |
mcp.callTool(client, name, arguments) | json.Value | call a tool; returns the whole result (check isError) |
mcp.listResources(client) | json.Value | the resources array |
mcp.readResource(client, uri) | json.Value | read a resource; returns the whole resources/read result |
mcp.listPrompts(client) | json.Value | the prompts array |
mcp.getPrompt(client, name, arguments) | json.Value | get a prompt; returns the whole prompts/get result |
def c as mcp.Client init mcp.connect("http://127.0.0.1:8080/");
def info as json.Value init mcp.initialize($c);
def args as json.Value init json.set(json.map(), "/text", "hello");
def out as json.Value init mcp.callTool($c, "echo", $args);
io.printf("isError=%t text=%s\n",
json.asBool($out, "/isError"), json.asString($out, "/content/0/text"));Scope
- Stateless protocol only. No SSE, no session lifecycle.
handletreats each request independently;notifications/initialized(and any othernotifications/message) is accepted and answered with no reply. - Transports. The stdio server (
serveStdio) and a transport-agnostichandle(wire tohttpd/net) are implemented on the server side; the HTTP client (connect+ the call verbs, overjsonrpc) and the stdio subprocess client (connectStdio, overos.run) are implemented on the client side. - The stdio client is one-shot. Because
os.runhas no persistent child pipe,connectStdiorelaunches the server per call: it writes theinitializehandshake plus the one operation as newline-delimited JSON on the child's stdin, closes it, and reads the replies from stdout (correlating byid). A stateless server therefore sees a complete short session each call; a persistent, interleaved session (many calls over one launched process) would need the streaming-pipe primitive noted inosas a follow-on.connectStdioneeds the default binary (os.run). - Handler result shape. A tool handler returns a
json.Valueor a scalar; a raw Jenniferlist/map/ struct is not coerced - build ajson.Valueresult explicitly. Structured (non-text) tool content and resource subscriptions are later adds.
See also
- jsonrpc.md - the JSON-RPC 2.0 client / server MCP is built on.
- json.md - the value type params and results are built from and read with.
- httpd.md / http.md - the server engine and client transport.
- web.md - the web framework, the other
func-value-dispatched request/response module. - modules/index.md - the module catalog and import rules.