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 withMcp-Session-Idsession 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/mcpConnecting 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 hereEvery 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}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_respawncovers hangs and crashes. A request-watchdog timeout closes the session with the typedMCPTimeoutError; a server that dies abruptly — killed, crashed, or its stdio pipe broken — closes it too, surfacing the typedMCPCrashError(carrying the exit code or signal when known) on the call in flight. Either way the next call respawns the server whenauto_respawn=trueand 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; itselapsedreflects 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.0Registering 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: 1Registering 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.
@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.
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 + 1Serving
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 closedThe 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
- Tool Calling Guide — function tools and automated tool loop
- MCP API Reference — full type and function reference
- Timeouts & Retries — MCP bounds and the concurrency-1 contract
CallableTool,tool_loop!,tool_loop— tool loop integration