Model Context Protocol (MCP)

UniLM.jl provides native MCP support — both as a client (connect to MCP servers) and a server (build your own). MCP tools integrate seamlessly with tool_loop! and tool_loop via the CallableTool bridge.

Protocol: JSON-RPC 2.0 over stdio or Streamable HTTP (MCP spec 2025-11-25). Zero external dependencies.


MCP Client

Transports

Two transport types are available:

  • StdioTransport — launches a subprocess, communicates via stdin/stdout (newline-delimited JSON-RPC)
  • HTTPTransport — communicates via POST requests with Mcp-Session-Id session management
# Transport types are constructed but not connected until mcp_connect
t1 = StdioTransport(`echo hello`)
println("Stdio transport for: ", t1.command)

t2 = HTTPTransport("https://mcp.example.com/mcp";
    headers=["Authorization" => "Bearer token"])
println("HTTP transport for: ", t2.url)
Stdio transport for: `echo hello`
HTTP transport for: https://mcp.example.com/mcp

Connecting to an MCP Server

Use mcp_connect with a Cmd (stdio), URL string (HTTP), or transport object:

# Stdio — launches subprocess
session = mcp_connect(`npx -y @modelcontextprotocol/server-filesystem /tmp`)

# HTTP
session = mcp_connect("https://mcp.example.com/mcp";
    headers=["Authorization" => "Bearer token"])

# Custom transport
session = mcp_connect(StdioTransport(`my-server`))

The do-block form automatically disconnects when done:

mcp_connect(`npx server`) do session
    tools = mcp_tools(session)
    # ... use tools ...
end  # session is disconnected here
A session is concurrency-1

Every call on an MCPSession — its liveness check, id allocation and request/response exchange — runs under one session lock, so a concurrent caller waits for the exchange in progress. The queued caller's mcp_request_timeout is measured from the moment it takes the lock, not from when it asked, so waiting behind another call never counts against its own bound; the wait stays bounded transitively, because the call ahead runs under that same per-exchange bound. mcp_disconnect! takes the lock too, so a disconnect racing a call in flight waits for that exchange to finish instead of tearing the transport down under its reader. For real parallelism, open one session per concurrent worker — see Concurrency.

Discovering Tools, Resources, and Prompts

After connecting, the session auto-populates tool/resource/prompt caches. You can also refresh them manually:

tools    = list_tools!(session)     # -> Vector{MCPToolInfo}
resources = list_resources!(session) # -> Vector{MCPResourceInfo}
prompts  = list_prompts!(session)   # -> Vector{MCPPromptInfo}
Server notifications

MCP servers may send notifications at any time, interleaved with responses; the client skips them transparently and answers server ping requests. After a notifications/tools/list_changed, the session's cached tool list is marked stale — check session.tools_stale and call list_tools! to refresh.

# MCPToolInfo fields
info = MCPToolInfo(Dict{String,Any}(
    "name" => "read_file",
    "description" => "Read a file from disk",
    "inputSchema" => Dict{String,Any}(
        "type" => "object",
        "properties" => Dict{String,Any}(
            "path" => Dict{String,Any}("type" => "string", "description" => "File path")
        ),
        "required" => ["path"]
    )
))
println("Tool: ", info.name)
println("Description: ", info.description)
println("Schema: ", JSON.json(info.input_schema, 2))
Tool: read_file
Description: Read a file from disk
Schema: {
  "properties": {
    "path": {
      "description": "File path",
      "type": "string"
    }
  },
  "required": [
    "path"
  ],
  "type": "object"
}

Calling Tools Directly

result = call_tool(session, "read_file", Dict{String,Any}("path" => "/tmp/data.txt"))
content = read_resource(session, "config://app")
messages = get_prompt(session, "review", Dict{String,Any}("code" => "x + 1"))
ping(session)

Timeouts

Every mcp_connect handshake and every call_tool / list_tools! exchange runs under a bound — set per session at connect time, or overridden for one call:

session = mcp_connect(`npx server`;
    config=RequestConfig(current_config(); mcp_connect_timeout=10.0, mcp_request_timeout=30.0),
    auto_respawn=true)

call_tool(session, "read_file", Dict{String,Any}("path" => "/tmp/data.txt"); timeout=5.0)

A stdio request timeout closes the session — stdio framing carries no request-id demux, so a late reply could misdeliver to the next caller. With auto_respawn=true the next call transparently respawns the server (same command, fresh handshake; in-memory server state is lost and tools are refetched); without it, the next call raises an error naming the opt-in. An HTTP request timeout does not close the session — each exchange is an independent POST. Both surface as the typed MCPTimeoutError.

Two boundaries of that contract, observed against real servers:

  • auto_respawn covers hangs and crashes. A request-watchdog timeout closes the session with the typed MCPTimeoutError; a server that dies abruptly — killed, crashed, or its stdio pipe broken — closes it too, surfacing the typed MCPCrashError (carrying the exit code or signal when known) on the call in flight. Either way the next call respawns the server when auto_respawn=true and errors naming the opt-in otherwise. HTTP sessions are unaffected: each exchange is an independent POST.
  • When a wedged server also ignores polite shutdown (frozen rather than merely slow), the timed-out call returns only after the escalation ladder completes: the configured timeout plus up to ~7 seconds of stdin-EOF and SIGTERM grace before the final process-group SIGKILL unblocks the read. The error is still the typed MCPTimeoutError; its elapsed reflects that full wall time.

Bridging to tool_loop! (Chat Completions)

mcp_tools converts MCP tools into Vector{CallableTool{Tool}} for use with tool_loop!:

session = mcp_connect(`npx server`)
tools = mcp_tools(session)

chat = Chat(model="gpt-5.2", tools=tools)
push!(chat, Message(Val(:system), "You are a helpful assistant."))
push!(chat, Message(Val(:user), "List files in /tmp"))
result = tool_loop!(chat; tools)

mcp_disconnect!(session)

Bridging to tool_loop (Responses API)

mcp_tools_respond converts MCP tools into Vector{CallableTool{FunctionTool}} for use with tool_loop:

session = mcp_connect("https://mcp.example.com/mcp")
tools = mcp_tools_respond(session)
result = tool_loop("List files in /tmp"; tools=tools)
mcp_disconnect!(session)

MCP Server

Creating a Server

server = MCPServer("calc", "1.0.0"; description="A calculator server")
println("Server: ", server.name, " v", server.version)
Server: calc v1.0.0

Registering Tools

Register tools with explicit JSON Schema or auto-inferred schema:

# Explicit schema
register_tool!(server, "add", "Add two numbers",
    Dict{String,Any}(
        "type" => "object",
        "properties" => Dict{String,Any}(
            "a" => Dict{String,Any}("type" => "number"),
            "b" => Dict{String,Any}("type" => "number")
        ),
        "required" => ["a", "b"]
    ),
    args -> string(args["a"] + args["b"]))

println("Registered tools: ", collect(keys(server.tools)))
Registered tools: ["add"]
# Auto-inferred schema from function signature
register_tool!(server, "greet", "Greet someone",
    (args::Dict{String,Any}) -> "Hello, $(args["name"])!")

println("Tools now: ", collect(keys(server.tools)))
Tools now: ["add", "greet"]

You can also register existing CallableTool instances:

# From Chat Completions tool
register_tool!(server, my_callable_gpt_tool)

# From Responses API tool
register_tool!(server, my_callable_function_tool)

Registering Resources

# Static resource
register_resource!(server, "config://app", "App Config",
    () -> "{\"debug\": true}";
    mime_type="application/json",
    description="Application configuration")

println("Resources: ", collect(keys(server.resources)))
Resources: ["config://app"]
# URI-templated resource
register_resource_template!(server, "file://{path}", "File Reader",
    (params::Dict{String,String}) -> "Contents of $(params["path"])";
    description="Read files by path")

println("Templates: ", length(server.resource_templates))
Templates: 1

Registering Prompts

register_prompt!(server, "review", (args::Dict{String,Any}) ->
    [Dict{String,Any}("role" => "user",
        "content" => Dict{String,Any}("type" => "text",
            "text" => "Review this code:\n$(args["code"])"))];
    description="Code review prompt",
    arguments=[Dict{String,Any}("name" => "code", "required" => true)])

println("Prompts: ", collect(keys(server.prompts)))
Prompts: ["review"]

Macros

The @mcp_tool, @mcp_resource, and @mcp_prompt macros provide a more ergonomic registration API with automatic JSON Schema generation from Julia type annotations. The examples below are executed at doc-build time and call the registered handlers directly to prove the wiring works.

@mcp_tool — typed args become JSON Schema

A named typed function becomes a tool whose inputSchema is inferred from the argument types (typed args are required). The generated handler unpacks the incoming Dict{String,Any} and forwards it to your function.

Contract

@mcp_tool requires a named function (function name(args…)). An anonymous function(x) … end now raises a clear error instead of registering a tool named after its first argument.

srv = MCPServer("calc", "1.0.0")

@mcp_tool srv function add(a::Float64, b::Float64)::String
    string(a + b)
end

# Generated schema, inferred from the signature:
println("schema: ", JSON.json(srv.tools["add"].input_schema))
# Call the registered handler with raw JSON-shaped args:
println("add(2, 3) = ", srv.tools["add"].handler(Dict{String,Any}("a" => 2.0, "b" => 3.0)))
schema: {"properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"type":"object"}
add(2, 3) = 5.0

@mcp_resource — static and URI-templated

A function() registers a static resource; a URI with {param} placeholders registers a template whose handler arguments are bound from the matched path params.

Contract

For template resources, each handler argument name must match a URI {param} name — it is bound from the matched params (e.g. {id}function(id::String)).

@mcp_resource srv "config://app" function()
    "{\"debug\": true}"
end

@mcp_resource srv "note://{id}" function(id::String)
    "Note #$id"
end

# Read the templated resource via JSON-RPC; the {id} param is bound into `id`:
req = Dict{String,Any}("jsonrpc" => "2.0", "id" => 1, "method" => "resources/read",
    "params" => Dict{String,Any}("uri" => "note://42"))
resp = UniLM._dispatch_mcp(srv, req)
println("note://42 -> ", resp["result"]["contents"][1]["text"])
note://42 -> Note #42

@mcp_prompt — args bound from the request

The anonymous function(arg::String) … end form registers a prompt; declared args are bound from the prompts/get request arguments.

@mcp_prompt srv "review" function(code::String)
    [Dict("role" => "user",
        "content" => Dict("type" => "text", "text" => "Review: $code"))]
end

# Dispatch prompts/get; `code` is bound from the request arguments:
greq = Dict{String,Any}("jsonrpc" => "2.0", "id" => 2, "method" => "prompts/get",
    "params" => Dict{String,Any}("name" => "review",
        "arguments" => Dict{String,Any}("code" => "x + 1")))
gresp = UniLM._dispatch_mcp(srv, greq)
println("review -> ", gresp["result"]["messages"][1]["content"]["text"])
review -> Review: x + 1

Serving

Start the server with serve:

serve(server)                              # stdio (default) — for Claude Desktop/CLI
serve(server; transport=:http, port=3000)  # HTTP on port 3000 — blocks until closed

The HTTP transport blocks until the server is closed (like HTTP.serve). To keep working in the same session, pass block=false and close the returned server yourself:

handle = serve(server; transport=:http, port=3000, block=false)
# ... interact with the running server ...
close(handle)

The HTTP transport validates the Origin header (a DNS-rebinding defense the MCP spec requires): requests without an Origin header (curl, SDK clients) and requests from localhost origins always pass; any other browser origin gets 403 unless you allowlist it:

serve(server; transport=:http, port=3000,
      allowed_origins=["https://app.example.com"])

MCP Tool in Responses API

Separately from the client/server above, OpenAI's Responses API has a built-in MCPTool type for server-side MCP integration. This tells the model to connect to an external MCP server during response generation:

tool = mcp_tool("my-server", "https://mcp.example.com/sse";
    require_approval="never",
    allowed_tools=["read_file", "list_dir"])
println("Type: ", typeof(tool))
println("Label: ", tool.server_label)
println("URL: ", tool.server_url)
println("JSON: ", JSON.json(JSON.lower(tool)))
Type: MCPTool
Label: my-server
URL: https://mcp.example.com/sse
JSON: {"allowed_tools":["read_file","list_dir"],"require_approval":"never","server_label":"my-server","server_url":"https://mcp.example.com/sse","type":"mcp"}

This is distinct from the UniLM.jl MCP client — MCPTool delegates tool execution to OpenAI's servers, while mcp_connect runs tools locally.


See Also