Skip to content
Jennifer Programming Language

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).

jennifer
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.

jennifer
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
};
CallReturns
mcp.server(name, version)Serveran empty server with the given identity
mcp.addTool(server, name, description, inputSchema, handler)Serverregister a tool; inputSchema is a json.Value JSON Schema, handler a func value
mcp.addResource(server, uri, name, description, mimeType, handler)Serverregister a resource keyed by uri
mcp.addPrompt(server, name, description, arguments, handler)Serverregister a prompt template; arguments is a list of PromptArg (declared to the host, may be [])
mcp.promptArg(name, description, required)PromptArgone 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 a json.Value or 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 a json.Value array. Each message is {role, content} where content is 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):

CallReturns
mcp.schema()json.Valuea {"type":"object","properties":{},"required":[]} skeleton
mcp.property(schema, name, jsonType, description, required)json.Valueadd properties/<name> = {type, description}; append name to required when required is true
jennifer
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 "".

CallReturns
mcp.handle(server, requestBody)stringthe 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:

jennifer
import "mcp.j" as mcp;
# ... build $srv ...
mcp.serveStdio($srv);   # blocks, reading stdin until EOF

For HTTP, wire handle to httpd yourself:

jennifer
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

MethodResult
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's handle, which dispatches a request's method to any top-level func of that name, mcp.handle only ever invokes a handler registered in the server:

  • A tools/call looks 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 (by uri) and prompts (by name).
  • 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"}.

jennifer
def struct mcp.Client { rpc as jsonrpc.Client };
CallReturns
mcp.connect(endpoint)Clientbind to an MCP endpoint URL (HTTP)
mcp.connectWith(endpoint, headers)ClientHTTP, with extra request headers (auth, ...)
mcp.connectStdio(argv)Clientlaunch an MCP server subprocess and talk to it over stdio
mcp.initialize(client)json.Valuerun the initialize handshake (and send the required notifications/initialized on success); returns the server's result
mcp.listTools(client)json.Valuethe tools array
mcp.callTool(client, name, arguments)json.Valuecall a tool; returns the whole result (check isError)
mcp.listResources(client)json.Valuethe resources array
mcp.readResource(client, uri)json.Valueread a resource; returns the whole resources/read result
mcp.listPrompts(client)json.Valuethe prompts array
mcp.getPrompt(client, name, arguments)json.Valueget a prompt; returns the whole prompts/get result
jennifer
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. handle treats each request independently; notifications/initialized (and any other notifications/ message) is accepted and answered with no reply.
  • Transports. The stdio server (serveStdio) and a transport-agnostic handle (wire to httpd / net) are implemented on the server side; the HTTP client (connect + the call verbs, over jsonrpc) and the stdio subprocess client (connectStdio, over os.run) are implemented on the client side.
  • The stdio client is one-shot. Because os.run has no persistent child pipe, connectStdio relaunches the server per call: it writes the initialize handshake plus the one operation as newline-delimited JSON on the child's stdin, closes it, and reads the replies from stdout (correlating by id). 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 in os as a follow-on. connectStdio needs the default binary (os.run).
  • Handler result shape. A tool handler returns a json.Value or a scalar; a raw Jennifer list / map / struct is not coerced - build a json.Value result 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.