Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ http_server = serve_mcp_http(server; host="127.0.0.1", port=3010)
wait(http_server.http)
```

The package also provides a specialized, tools-only server with a concrete
request graph for JuliaC `--trim=safe` builds. Its API is namespaced and is not
exported. See the
[trim-safe static server guide](https://juliaservices.github.io/ModelContextProtocol.jl/stable/static-server/)
for the example and support limits.

## Minimal Client

```julia
Expand Down
1 change: 1 addition & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[deps]
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
ModelContextProtocol = "a630a0b0-4325-47f6-86f8-c7db38e4394e"

[compat]
Documenter = "1"
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ makedocs(
pages=[
"Home" => "index.md",
"Auth0 Federation Example" => "auth0.md",
"Trim-safe static server" => "static-server.md",
"API" => "api.md",
],
pagesonly=true,
Expand Down
14 changes: 14 additions & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@
- `broadcast_server_event!`
- `notify_resource_updated!`

## Trim-safe Static Server

These specialized names are not exported. Use them through the
`ModelContextProtocol` namespace. See the [static server guide](static-server.md)
for a complete example and the support limits.

- `ModelContextProtocol.StaticMCPServer`
- `ModelContextProtocol.StaticMCPTool`
- `ModelContextProtocol.StaticMCPToolResult`
- `ModelContextProtocol.StaticMCPRequestContext`
- `ModelContextProtocol.handle_static_jsonrpc_request`
- `ModelContextProtocol.handle_static_stream_request`
- `ModelContextProtocol.handle_static_session_delete`

## MCP Apps (SEP-1865)

Helpers for serving interactive HTML widgets that hosts render inline. See
Expand Down
5 changes: 5 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ tests.

The package currently targets MCP protocol version `2025-11-25`.

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
the general server.

## Installation

This JuliaServices package is currently unregistered. The General registry has
Expand Down
83 changes: 83 additions & 0 deletions docs/src/static-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Trim-safe Static Tools Server

The static server is a specialized alternative to `MCPServer`. It keeps the
tool dispatch graph concrete for JuliaC `--trim=safe` compilation. It supports
the MCP `2025-11-25` initialization, session, ping, tool-list, tool-call, and
session-delete flows.

The API is not exported. Use each name through the `ModelContextProtocol`
namespace.

## Create a server

Each tool handler must have the same concrete Julia type. One handler type can
dispatch by tool name when a server has more than one tool. Tool arguments,
schemas, annotations, and structured results use raw `JSON.JSONText` values.
The handler must validate its arguments before it performs work.

```@example static-server
using ModelContextProtocol

const MCPJSON = ModelContextProtocol.JSON

Base.@kwdef struct EchoArguments
message::String = ""
end

struct EchoHandler end

function (::EchoHandler)(
::ModelContextProtocol.StaticMCPRequestContext,
arguments::MCPJSON.JSONText,
)
parsed = MCPJSON.parse(arguments.value, EchoArguments)
return ModelContextProtocol.StaticMCPToolResult(
text=parsed.message,
structured_content=MCPJSON.JSONText(MCPJSON.json((; echoed=parsed.message))),
)
end

echo = ModelContextProtocol.StaticMCPTool(
name="echo",
description="Return the supplied message.",
input_schema=MCPJSON.JSONText(
"{\"type\":\"object\",\"properties\":{\"message\":{\"type\":\"string\"}},\"required\":[\"message\"]}",
),
annotations=MCPJSON.JSONText("{\"readOnlyHint\":true}"),
handler=EchoHandler(),
)

server = ModelContextProtocol.StaticMCPServer(
[echo];
name="Static Echo",
version="1.0.0",
)

only(server.tools).name
```

Register these handlers on one HTTP route:

```julia
HTTP = ModelContextProtocol.HTTP
router = HTTP.Router()
HTTP.register!(router, "POST", "/v1/mcp") do request
ModelContextProtocol.handle_static_jsonrpc_request(server, request)
end
HTTP.register!(router, "GET", "/v1/mcp") do request
ModelContextProtocol.handle_static_stream_request(server, request)
end
HTTP.register!(router, "DELETE", "/v1/mcp") do request
ModelContextProtocol.handle_static_session_delete(server, request)
end
```

## Support limits

The static server provides tools only. It does not provide prompts, resources,
completion, OAuth discovery, or unsolicited server events. Its `GET` handler
returns HTTP 405 because it does not open an event stream. Use `MCPServer` when
the application needs these features.

JuliaC verification covers in-memory HTTP requests and the complete static
server lifecycle. It does not cover live socket or TLS setup in HTTP.jl.
2 changes: 2 additions & 0 deletions src/ModelContextProtocol.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module ModelContextProtocol

using HTTP
using JSON
using UUIDs

include("types.jl")
include("errors.jl")
Expand All @@ -10,6 +11,7 @@ include("discovery.jl")
include("auth.jl")
include("jsonrpc.jl")
include("server.jl")
include("static_server.jl")
include("client.jl")
include("apps.jl")

Expand Down
40 changes: 39 additions & 1 deletion src/jsonrpc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,44 @@ function jsonrpc_call(
end
notification && return nothing
isempty(response.body) && throw(mcp_error(:jsonrpc_error, "JSON-RPC response from $(client.transport.url) was empty"))
data = parse_jsonrpc_response(response.body)
if is_event_stream_response(response)
data = extract_streamed_jsonrpc_response(client, response, payload["id"])
else
data = parse_jsonrpc_response(response.body)
end
return get(data, "result", nothing)
end

function is_event_stream_response(response::HTTP.Response)
content_type = http_header_value(response.headers, "Content-Type")
content_type === nothing && return false
return occursin("text/event-stream", lowercase(String(content_type)))
end

# Streamable HTTP servers may answer a POST with an SSE stream carrying
# request-scoped notifications/server requests before the final response.
function extract_streamed_jsonrpc_response(client::MCPClient, response::HTTP.Response, request_id)
result_payload = nothing
for event in parse_sse_events(String(response.body))
event.event in (nothing, "", "message", "jsonrpc") || continue
isempty(event.data) && continue
data = try
JSON.parse(event.data)
catch err
@warn "Failed to parse JSON from streamed response event" err
continue
end
data isa AbstractDict || continue
if haskey(data, "method")
handle_jsonrpc_event!(client, data)
elseif get(data, "id", nothing) == request_id && (haskey(data, "result") || haskey(data, "error"))
result_payload = data
end
end
result_payload === nothing && throw(mcp_error(:jsonrpc_error, "Streamed JSON-RPC response from $(client.transport.url) did not include a response for request $(request_id)"))
return validate_jsonrpc_payload(result_payload)
end

jsonrpc_notification(client::MCPClient, method::AbstractString; params=nothing, headers=nothing) =
jsonrpc_call(client, method; params=params, notification=true, headers=headers)

Expand Down Expand Up @@ -135,6 +169,10 @@ end

function parse_jsonrpc_response(body)
data = JSON.parse(String(body))
return validate_jsonrpc_payload(data)
end

function validate_jsonrpc_payload(data)
data isa AbstractDict || throw(mcp_error(:jsonrpc_error, "JSON-RPC response must be a JSON object"))
version = get(data, "jsonrpc", nothing)
version == JSONRPC_VERSION || throw(mcp_error(:jsonrpc_error, "Unsupported JSON-RPC version $(version)"))
Expand Down
18 changes: 11 additions & 7 deletions src/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ function call_tool(server::MCPServer, context::MCPRequestContext, params::Dict{S
haskey(params, "name") || throw(mcp_error(:invalid_params, "Tool call requires a name"))
name = String(params["name"])
tool = get(server.tools, name, nothing)
tool === nothing && throw(mcp_error(:method_not_found, "Tool $(name) is not registered"))
tool === nothing && throw(mcp_error(:invalid_params, "Tool $(name) is not registered"))
args = arguments_dict(params)
result = tool.handler(context, args)
normalized = normalize_tool_result(result)
Expand Down Expand Up @@ -1224,40 +1224,42 @@ function handle_completion_request(server::MCPServer, context::MCPRequestContext
return result
end

# Lists are returned in sorted order so results are deterministic across
# calls/instances (improves client-side LLM prompt cache hit rates).
function list_tools(server::MCPServer, params::Dict{String,Any})
items = [tool_descriptor(tool) for tool in values(server.tools)]
items = [tool_descriptor(server.tools[name]) for name in sort!(collect(keys(server.tools)))]
return paginate_collection(items, params, "tools")
end

function list_prompts(server::MCPServer, params::Dict{String,Any})
items = [prompt_descriptor(prompt) for prompt in values(server.prompts)]
items = [prompt_descriptor(server.prompts[name]) for name in sort!(collect(keys(server.prompts)))]
return paginate_collection(items, params, "prompts")
end

function get_prompt(server::MCPServer, context::MCPRequestContext, params::Dict{String,Any})
haskey(params, "name") || throw(mcp_error(:invalid_params, "Prompt retrieval requires a name"))
name = String(params["name"])
prompt = get(server.prompts, name, nothing)
prompt === nothing && throw(mcp_error(:method_not_found, "Prompt $(name) is not registered"))
prompt === nothing && throw(mcp_error(:invalid_params, "Prompt $(name) is not registered"))
args = arguments_dict(params)
return prompt.handler(context, args)
end

function list_resources(server::MCPServer, params::Dict{String,Any})
items = [resource_descriptor(resource) for resource in values(server.resources)]
items = [resource_descriptor(server.resources[uri]) for uri in sort!(collect(keys(server.resources)))]
return paginate_collection(items, params, "resources")
end

function list_resource_templates(server::MCPServer, params::Dict{String,Any})
items = [resource_template_descriptor(template) for template in values(server.resource_templates)]
items = [resource_template_descriptor(server.resource_templates[name]) for name in sort!(collect(keys(server.resource_templates)))]
return paginate_collection(items, params, "resourceTemplates")
end

function read_resource(server::MCPServer, context::MCPRequestContext, params::Dict{String,Any})
haskey(params, "uri") || throw(mcp_error(:invalid_params, "Resource retrieval requires a uri"))
uri = String(params["uri"])
resource = get(server.resources, uri, nothing)
resource === nothing && throw(mcp_error(:method_not_found, "Resource $(uri) is not registered"))
resource === nothing && throw(mcp_error(:resource_not_found, "Resource $(uri) is not registered"))
args = arguments_dict(params)
return resource.handler(context, args)
end
Expand Down Expand Up @@ -1390,6 +1392,8 @@ function classify_error(err)
return -32602, err.message
elseif err.code == :invalid_request
return -32600, err.message
elseif err.code == :resource_not_found
return -32002, err.message
elseif err.code == :invalid_session
return -32001, err.message
elseif err.code == :session_required
Expand Down
Loading
Loading