Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ The package supports Streamable HTTP for two protocol eras:
The general server accepts both versions by default. The client defaults to
`2025-11-25`. Applications opt in to `2026-07-28` with `MCPClientConfig`.

The client also owns local child processes through
`ModelContextProtocol.prepare_stdio_client`. It supports newline-delimited
JSON-RPC, concurrent calls, notifications, legacy server requests, cancellation,
and bounded process shutdown for either explicitly selected protocol version.

The package also includes a separate tools-only server for JuliaC
`--trim=safe` builds. That server intentionally supports only the documented
`2025-11-25` subset.
Expand All @@ -31,6 +36,8 @@ The repository tests these areas:
Windows.
- OAuth 2 and OAuth 3 compatibility.
- Stateful and stateless HTTP client/server integration.
- Owned stdio child processes, concurrent response correlation, malformed or
oversized frames, cancellation, blocked callbacks, and process shutdown.
- Strict JSON-RPC parsing and notification side-effect rules.
- Client result response IDs must match the request before results or session
state are accepted.
Expand All @@ -42,7 +49,12 @@ The repository tests these areas:

## Intentional limits

- The transport is HTTP only. The package does not provide a stdio transport.
- Stdio is a client transport only. It does not provide a stdio server,
protocol auto-detection, automatic restart/replay, modern subscriptions,
HTTP headers/OAuth, or automatic Agentif tool-catalog import. Callbacks must
cooperate with shutdown; a blocked callback produces an explicit close error.
- The general client, including subprocess stdio, is not a JuliaC trim-safe
API. The separate static server remains the supported native subset.
- Tool input and output schemas are advertised but are not a complete runtime
JSON Schema validation engine. A handler must still validate domain rules.
- Modern request-scoped progress and log events keep their correct order, but
Expand All @@ -63,7 +75,6 @@ The repository tests these areas:
without ending the server.
4. Evaluate a lightweight JSON Schema validator for tool arguments and
structured results.
5. Add a stdio transport only if a concrete Julia deployment needs it.

Do not add a feature only to increase surface coverage. Preserve the small
export surface. Keep specialized helpers under the `ModelContextProtocol`
Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ makedocs(
),
pages=[
"Home" => "index.md",
"Local stdio client" => "stdio.md",
"MCP 2026-07-28" => "protocol-2026.md",
"Auth0 Federation Example" => "auth0.md",
"Trim-safe static server" => "static-server.md",
Expand Down
8 changes: 8 additions & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ for the full guide.

## Client Functions

- `ModelContextProtocol.prepare_stdio_client`
- `discover_server`
- `prepare_manual_client`
- `attach_token!`
Expand All @@ -98,6 +99,13 @@ for the full guide.
- `stop_event_listener!`
- `terminate_session!`

### Owned stdio clients

```@docs
ModelContextProtocol.prepare_stdio_client
Base.close(::MCPClient)
```

## MCP 2026-07-28

The modern low-level helpers stay under the package namespace to keep the
Expand Down
11 changes: 7 additions & 4 deletions docs/src/index.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# ModelContextProtocol.jl

`ModelContextProtocol.jl` provides Julia server and client utilities for the
Model Context Protocol (MCP). It focuses on Streamable HTTP servers, discovery
manifests, OAuth-protected resources, JSON-RPC request handling, tools, prompts,
resources, completions, logging notifications, and lightweight client smoke
tests.
Model Context Protocol (MCP). It supports Streamable HTTP servers and clients,
local subprocess clients, discovery manifests, OAuth-protected resources,
JSON-RPC request handling, tools, prompts, resources, completions, and logging
notifications.

The package supports the stateful MCP `2025-11-25` protocol and the stateless
MCP `2026-07-28` protocol over Streamable HTTP. Clients default to
`2025-11-25` for compatibility. See [MCP 2026-07-28](protocol-2026.md) for
modern client setup, capability checks, multi-round-trip results, custom
headers, and subscriptions.

For a server launched as a local command, see the [stdio client guide](stdio.md).
The same initialization, list, and call APIs work with an owned child process.

For deployments that require a concrete request graph, see the
[trim-safe static tools server](static-server.md). This API stays under the
`ModelContextProtocol` namespace because it is a specialized alternative to
Expand Down
109 changes: 109 additions & 0 deletions docs/src/stdio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Local stdio client

Use `ModelContextProtocol.prepare_stdio_client(command)` to launch a local MCP
server and own its stdin/stdout connection. The child must exchange one UTF-8
JSON-RPC object per line. Diagnostic output belongs on stderr. The constructor
accepts a Julia `Cmd`; it does not run a shell or interpret a command string.

This example uses the repository's deterministic echo peer. Replace `command`
with your server's command in an application. The do-block closes the child
when the body returns or throws.

```@example stdio
using ModelContextProtocol

peer = joinpath(pkgdir(ModelContextProtocol), "test", "stdio_peer.jl")
project = dirname(Base.active_project())
command = `$(Base.julia_cmd()) --startup-file=no --project=$project $peer`

ModelContextProtocol.prepare_stdio_client(command; stderr=devnull) do client
initialize_client!(client)
@assert list_tools(client)["tools"][1]["name"] == "echo"
result = call_tool(client, "echo"; arguments=Dict("message" => "Hello, λ"))
println(result["structuredContent"]["message"])
end
```

Without a do-block, use `try`/`finally` and call `close(client)` or
`terminate_session!(client)`. The client owns the direct child and its protocol
pipes. It does not manage a process tree created by that child. Pass the server
executable directly when possible.

## Protocol versions

The default is MCP `2025-11-25`. Select `2026-07-28` explicitly with
`MCPClientConfig(protocol_version=ModelContextProtocol.PROTOCOL_VERSION_2026_07_28)`.

| Behavior | 2025-11-25 | 2026-07-28 |
|:--|:--|:--|
| `initialize_client!` | Initialize, then initialized notification | `server/discover` |
| Request identity/capabilities | Initialization parameters | Per-request `_meta` |
| Lists, calls, and notifications | Supported | Supported |
| Server requests | Existing registered request handlers | Rejected by the protocol |
| Multi-round-trip input | Application-managed | Existing `input_required` helpers |
| Request timeout | Cancellation notification | Cancellation notification |
| Subscriptions | Existing legacy resource calls | Not implemented |

The implementation follows the dated
[2025-11-25 transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
and [lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)
rules, and the
[2026-07-28 stdio transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio)
rules for the operations above. It does not probe versions, fall back to another
protocol, restart a child, or replay requests automatically. HTTP headers,
OAuth bearer tokens, HTTP event streams, and modern `subscriptions/listen` are
rejected, as are custom HTTP adapters and verbose HTTP logging. Stdio has no
HTTP header transport; `x-mcp-header` tool arguments are
sent as ordinary arguments. Importing a server's catalog into Agentif is a
separate application concern.

## Concurrent calls and handlers

Register notification/request handlers and complete initialization before
starting concurrent calls. Calls receive unique string IDs; out-of-order
responses are matched by the exact ID. A late response to an expired call is
discarded. EOF, invalid JSON/UTF-8, mismatched IDs, and oversized frames fail
pending calls and start process cleanup.

Notifications and legacy server requests use the existing handler registration
APIs. One callback task preserves arrival order, independently of response
reading. A handler can make a nested client call. A slow handler delays other
callbacks but does not stop response routing. Closing discards queued callbacks
that have not started.

## Bounds and shutdown

`config.timeout.readtimeout` defaults to 120 seconds. Each call can override it
with a positive integer `timeout_ms`. This deadline covers queued writes and
response waits. It does not preempt application JSON serialization or OS process
creation. A response timeout sends cancellation, except for legacy initialize;
a write timeout closes the connection because a partial frame cannot be safely
replayed. `connecttimeout` has no effect for a local process; other timeout
settings are rejected.

`max_message_bytes` defaults to 16 MiB per incoming/outgoing message.
`max_pending_messages` defaults to 128 and separately limits pending calls,
queued writes, and queued callbacks. A full call/write queue reports
`MCPError(:transport_busy)`. Callback overflow fails the connection with
`MCPError(:callback_overflow)`. These are queue/frame limits, not a total memory
quota for parsed JSON or user code.

Stderr can go directly to a filename, open file, terminal, pipe, or `devnull`.
Caller-provided destinations remain caller-owned. In-memory/custom IO sinks
are rejected because their implicit copy tasks cannot be bounded by the client.
If a pipe destination blocks, requests still have deadlines and closing can
terminate the child.

`close(client; timeout=5.0)` stops new calls, fails pending calls, closes stdin,
and escalates to process termination and kill if necessary. It waits for owned
IO and callback tasks within the supplied deadline. Cleanup state remains
available if close times out, so a later `close` can finish waiting.

Julia cannot safely interrupt arbitrary callback code. A blocked user callback
can produce `MCPError(:callback_timeout)` after process/IO cleanup. Release that
callback and close again. A callback may call `close` itself; close skips waiting
for that callback, which finishes when its handler returns. This transport does
not provide forced task cancellation.

The dynamic subprocess client is not a JuliaC `--trim=safe` API. The package's
[static tools server](static-server.md) remains its supported native subset.
1 change: 1 addition & 0 deletions src/ModelContextProtocol.jl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ include("jsonrpc.jl")
include("server.jl")
include("static_server.jl")
include("client.jl")
include("stdio.jl")
include("apps.jl")

export MCPError, MCPAuthenticationRequired
Expand Down
27 changes: 25 additions & 2 deletions src/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ default_client_info() = Dict(

function list_tools(client::MCPClient; cursor=nothing, limit=nothing, headers=nothing, timeout_ms=nothing)
result = list_entities(client, JSONRPC_METHOD_TOOLS_LIST; cursor=cursor, limit=limit, headers=headers, timeout_ms=timeout_ms)
client_is_modern(client) || return result
(client_is_modern(client) && client.transport.kind == :http) || return result
result isa AbstractDict || return result
tools = get(result, "tools", nothing)
tools isa AbstractVector || return result
Expand Down Expand Up @@ -118,6 +118,7 @@ apply_mrtr_params!(params::Dict{String,Any}, input_responses, request_state) = b
end

function custom_tool_headers(client::MCPClient, name::String, arguments)
client.transport.kind == :stdio && return HeaderPair[]
client_is_modern(client) || return HeaderPair[]
if !haskey(client.tool_schemas, name)
cursor = nothing
Expand Down Expand Up @@ -202,6 +203,7 @@ function listen_subscriptions!(
resource_uris=String[],
headers=nothing,
)
ensure_http_transport(client.transport)
client_is_modern(client) || throw(mcp_error(:unsupported_protocol_version, "subscriptions/listen requires protocol version >= $(PROTOCOL_VERSION_2026_07_28)"))
notifications = Dict{String,Any}()
tools_list_changed && (notifications["toolsListChanged"] = true)
Expand Down Expand Up @@ -303,11 +305,16 @@ function initialize_client!(
client::MCPClient;
protocol_version::AbstractString=client.protocol_version,
capabilities=nothing,
client_info=default_client_info(),
client_info=client.transport.kind == :stdio ? client.client_info : default_client_info(),
extra_params=nothing,
headers=nothing,
timeout_ms=nothing,
)
if client.transport.kind == :stdio
String(protocol_version) == client.protocol_version ||
throw(ArgumentError("Choose the stdio protocol version in MCPClientConfig before starting the child"))
capabilities === nothing && (capabilities = client.capabilities)
end
if client_is_modern(client)
# Modern protocol has no initialize handshake; record identity for
# per-request _meta and use server/discover for capability discovery.
Expand All @@ -327,6 +334,11 @@ function initialize_client!(
merge_extra_params!(params, extra_params)
result = jsonrpc_call(client, JSONRPC_METHOD_INITIALIZE; params=params, headers=headers, timeout_ms=timeout_ms)
session_data = result isa AbstractDict ? to_json_dict(result) : Dict{String,Any}()
if client.transport.kind == :stdio
get(session_data, "protocolVersion", nothing) == String(protocol_version) ||
throw(mcp_error(:unsupported_protocol_version, "The stdio server did not accept protocol version $(protocol_version)"))
client.protocol_version = String(protocol_version)
end
client.session = session_data
client.last_event_id = nothing
send_initialized_notification!(client; headers=headers)
Expand All @@ -341,6 +353,7 @@ function cancel_request(client::MCPClient, request_id; reason=nothing, headers=n
end

function open_event_stream(client::MCPClient; headers=nothing, timeout=nothing)
ensure_http_transport(client.transport)
client_is_modern(client) && throw(mcp_error(:unsupported_protocol_version, "The 2026-07-28 protocol uses subscriptions/listen instead of a standalone event stream"))
client.initialized || throw(mcp_error(:not_initialized, "Client must be initialized before opening an event stream"))
header_pairs = normalize_headers(headers)
Expand Down Expand Up @@ -459,6 +472,10 @@ function send_jsonrpc_response!(client::MCPClient, id; result=nothing, error=not
payload["error"] = normalize_jsonrpc_response_error(error)
end
body = JSON.json(payload)
if client.transport.kind == :stdio
stdio_check_headers(client, headers)
return stdio_write!(client, body, stdio_deadline(client, timeout, nothing))
end
response = submit_jsonrpc_request(client, body; headers=normalize_headers(headers), timeout=timeout)
return response
end
Expand Down Expand Up @@ -584,6 +601,7 @@ function event_listener_loop(client::MCPClient, poll_interval::Real, headers)
end

function start_event_listener!(client::MCPClient; poll_interval::Real=1.0, headers=nothing)
ensure_http_transport(client.transport)
client_is_modern(client) && throw(mcp_error(:unsupported_protocol_version, "The 2026-07-28 protocol uses listen_subscriptions! instead of the legacy event listener"))
client.initialized || throw(mcp_error(:not_initialized, "Client must be initialized before starting event listener"))
stop_event_listener!(client)
Expand Down Expand Up @@ -615,6 +633,11 @@ function stop_event_listener!(client::MCPClient)
end

function terminate_session!(client::MCPClient; headers=nothing, timeout=nothing)
if client.transport.kind == :stdio
stdio_check_headers(client, headers)
seconds = timeout === nothing ? 5.0 : stdio_timeout_seconds(client, timeout, nothing)
return close(client; timeout=seconds)
end
if client_is_modern(client)
stop_event_listener!(client)
client.session = nothing
Expand Down
8 changes: 6 additions & 2 deletions src/jsonrpc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ function jsonrpc_call(
timeout=nothing,
timeout_ms=nothing,
)
if client.transport.kind == :stdio
return stdio_jsonrpc_call(client, method; params, notification, headers, timeout, timeout_ms)
end
ensure_http_transport(client.transport)
ensure_client_readiness(client, String(method), notification)
method_str = String(method)
Expand Down Expand Up @@ -273,15 +276,16 @@ end

function ensure_client_readiness(client::MCPClient, method::AbstractString, notification::Bool)
if client_is_modern(client)
method == JSONRPC_METHOD_NOTIFICATIONS_CANCELLED &&
method == JSONRPC_METHOD_NOTIFICATIONS_CANCELLED && client.transport.kind == :http &&
throw(mcp_error(:unsupported_protocol_version, "Streamable HTTP cancellation in the 2026-07-28 protocol closes the response stream"))
method in LEGACY_ONLY_METHODS && throw(mcp_error(:unsupported_protocol_version, "$(method) is not part of the 2026-07-28 protocol"))
return
end
if method == JSONRPC_METHOD_INITIALIZE
return
elseif method == JSONRPC_METHOD_NOTIFICATIONS_INITIALIZED
client.session_id === nothing && throw(mcp_error(:session_required, "Cannot send notifications/initialized before establishing a session"))
ready = client.transport.kind == :stdio ? client.session !== nothing : client.session_id !== nothing
ready || throw(mcp_error(:session_required, "Cannot send notifications/initialized before establishing a session"))
return
end
client.initialized || throw(mcp_error(:not_initialized, "Client must complete initialization before calling $(method)"))
Expand Down
Loading
Loading