mcp

MCP client for connecting to Model Context Protocol servers from Hot. It supports the stateless 2026-07-28 protocol and the legacy initialize-based protocols on the same API.

Installation

Add this to the deps in your hot.hot file:

"hot.dev/mcp": "1.4.0"

This package depends on hot.dev/json-rpc for JSON-RPC 2.0 message framing (installed automatically).

Usage

Connect to an MCP Server

connect probes server/discover and uses 2026-07-28 when available. It only falls back to the legacy initialize handshake when the endpoint is not recognizable as a modern server.

::mcp ::mcp::client
::types ::mcp::types

session ::mcp/connect(
  "https://my-server.example.com/mcp",
  types/ClientInfo({name: "my-app", version: "1.0.0"}),
  types/ClientCapabilities({elicitation: {form: {}}}),
  types/ConnectionOptions({
    headers: {"Authorization": "Bearer ..."},
    log-level: "info"
  })
)

println(session.server-info.name)
println(session.protocol-version)

Use ::mcp/initialize(...) when a caller must explicitly use the legacy handshake. ConnectionOptions.mode can also be set to "modern" or "legacy" to pin an era.

Discover and Call Tools

::tools ::mcp::tools

tools ::tools/list(session)
for-each(tools, fn (tool) { println(tool.name) })

result ::tools/call(session, "get_weather", {location: "Portland"})
println(first(result.content).text)

Modern calls automatically emit MCP-Protocol-Version, Mcp-Method, Mcp-Name, and any valid x-mcp-header parameter headers required by the listed tool definition.

Fulfill Multi Round-Trip Input

The 2026-07-28 protocol returns server-to-client requests as an input_required result. call-with-input handles the retry loop, creates a fresh JSON-RPC ID for every round, and echoes the opaque request state.

result ::tools/call-with-input(
  session,
  "publish_report",
  {report-id: "rpt-42"},
  fn (request) {
    cond {
      eq(request.method, "elicitation/create") => {
        {action: "accept", content: {confirmed: true}}
      }
      => { err(`Unsupported input request: ${request.method}`) }
    }
  },
  3
)

Stream a Tool Call

For long-running tools, use call-stream to receive progress notifications and the final result as SSE events.

::tools ::mcp::tools

for-each(::tools/call-stream(session, "long_analysis", {query: "..."}), fn (event) {
  cond {
    eq(get(event.data, "method"), "notifications/progress") => {
      println(`Progress: ${event.data.params.message}`)
    }
    is-some(get(event.data, "result")) => {
      println(`Done: ${event.data.result}`)
    }
    => { null }
  }
})

Read Resources

::resources ::mcp::resources

resources ::resources/list(session)
for-each(resources, fn (r) { println(`${r.name}: ${r.uri}`) })

contents ::resources/read(session, "file:///project/README.md")
println(first(contents).text)

Get Prompts

::prompts ::mcp::prompts

prompts ::prompts/list(session)

messages ::prompts/get-prompt(session, "code-review", {language: "hot", code: "add(1, 2)"})
for-each(messages, fn (m) { println(`${m.role}: ${m.content.text}`) })

Listen for Modern Notifications

::subscriptions ::mcp::subscriptions
::types ::mcp::types

events ::subscriptions/listen(
  session,
  ::types/SubscriptionFilter({tools-list-changed: true})
)
for-each(events, fn (event) { println(event.data) })

The helper checks each opt-in against the capabilities returned by discovery.

Paginate Through All Tools

::tools ::mcp::tools

// Automatic: fetches all pages
all-tools ::tools/list-all(session)

// Manual: page by page
first-page ::tools/list-page(session, null)
if(is-some(first-page.next-cursor),
  ::tools/list-page(session, first-page.next-cursor),
  null)

End-to-End Example

::myapp::agent ns

::mcp ::mcp::client
::tools ::mcp::tools
::types ::mcp::types

run fn () {
  // Connect to an MCP server
  session ::mcp/connect(
    "https://my-server.example.com/mcp",
    types/ClientInfo({name: "my-app", version: "1.0.0"}),
    null
  )

  println(`Connected to ${session.server-info.name}`)

  // List available tools
  tools ::tools/list(session)
  println(`${length(tools)} tools available`)

  // Find and call a specific tool
  tool first(filter(tools, fn (t) { eq(t.name, "search") }))
  if(is-some(tool), {
    result ::tools/call(session, tool.name, {query: "hello world"})
    println(first(result.content).text)
  }, {
    println("search tool not found")
  })
}

Modules

ModuleDescription
::mcp::clientAutomatic discovery, legacy initialization, request codecs, and MRTR helpers
::mcp::toolsTool listing, validated routing headers, calls, MRTR, and SSE streaming
::mcp::resourcesResource listing, reading, caching metadata, and MRTR
::mcp::promptsPrompt listing, retrieval, caching metadata, and MRTR
::mcp::subscriptionsCapability-gated 2026-07-28 notification streams
::mcp::typesAll MCP type definitions (Session, Tool, Resource, Prompt, etc.)

Protocol

This package implements the Model Context Protocol 2026-07-28 stateless Streamable HTTP protocol and remains compatible with initialize-based Streamable HTTP servers using 2025-11-25, 2025-06-18, or 2025-03-26. The Hot server separately retains its deprecated 2024-11-05 HTTP+SSE endpoints for older clients. The package uses hot.dev/json-rpc for JSON-RPC 2.0 framing.

Documentation

License

Apache-2.0