MCP client
Connect nimgent to an MCP server to discover tools, resources, prompts, and tasks at runtime. You can call discovered tools directly or make them available to an agent, without adding each capability to your Nim application.
Connect
Section titled “Connect”Everything lives in nimgent/mcp. Use stdio when nimgent should spawn a local
server process, or Streamable HTTP for a server that already has an endpoint.
import std/[asyncdispatch, json, os, sequtils, strutils]import nimgent/mcp
proc main() {.async.} = let client = await connectMcpStdioAsync( @["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]) defer: client.close()
let tools = await client.listToolsAsync() echo "discovered: ", tools.mapIt(it.name).join(", ")
waitFor main()For Streamable HTTP, connect to the server endpoint directly. The optional second argument is a bearer token.
let client = await connectMcpHttpAsync( "https://mcp.example.com/mcp", bearerToken = getEnv("MCP_TOKEN"))defer: client.close()The first element of the command is the program, the rest are arguments.
connectMcpStdioAsync also takes a working directory and an environment table
(env: StringTableRef), so a server can be launched with its own config without
mutating your process environment.
connectMcpStdio is the blocking twin for scripts. Both complete the handshake
before returning; if it fails, the process is closed and the error propagates
rather than leaving a half-open client behind.
What the handshake does
Section titled “What the handshake does”The client pins the modern stateless revision, mcpProtocolVersion
(2026-07-28), and will not drift to whatever else the server would accept.
Connecting means:
server/discover- the server reports the versions it supports.- nimgent checks the pinned version is among them, and fails loudly if not.
- The server’s identity and full discovery document are kept on the client as
client.serverInfoandclient.discovery.
There is deliberately no fallback to the older initialize/session
handshake. A server that has removed it is one nimgent cannot talk to, and you
get an McpClientError saying so instead of a protocol mismatch halfway
through a run. Every request also carries the protocol version, client name,
and client capabilities in its _meta. requestAsync accepts complete,
input-required, and task results. High-level calls automatically retry
input-required results when you configure the matching input handler.
Call a tool directly
Section titled “Call a tool directly”You do not need an agent to use MCP. Discovery and invocation are ordinary calls:
let result = await client.callToolAsync("read_file", %*{"path": "/tmp/notes.md"})echo result.content # the raw MCP content arrayecho result.structuredContent # structured output, when the server sends itecho result.isErrorMcpCallResult keeps the server’s answer close to the wire: content is the
content array as sent, structuredContent is the optional structured form, and
isError is the server’s own error flag. Tool failures come back as results, not
exceptions.
Hand the tools to an agent
Section titled “Hand the tools to an agent”asToolsAsync converts the discovered list into ordinary nimgent Tool values -
each one carrying the server’s schema and description, with execution routed
back over the client:
let remoteTools = await client.asToolsAsync(prefix = "fs_")
let assistant = newAgent( model, instructions = "You answer questions about the user's files.", tools = remoteTools, maxSteps = 5)
echo assistant.run("What is in notes.md?").textPast that call, nothing is special. Remote tools go through the same argument
validation, the same failure handling, and the same traces as local ones. Text
content is joined into the model-facing output; when the server sends only
structured content, that is serialized instead. A call the server reports as an
error becomes a tool failure with code mcp_tool_error, which the model can read
and react to.
Use prefix when the server’s names are generic (read, search) and could
collide with your own tools. The prefix applies to the local name only - the
server still sees its original name on the wire - and names must stay unique
after prefixing, or generateText rejects the run before calling anything.
Resources, prompts, and completions
Section titled “Resources, prompts, and completions”Resources and prompts are available as typed client results:
let resources = await client.listResourcesAsync()let file = await client.readResourceAsync(resources[0].uri)echo file.contents[0].text
let prompt = await client.getPromptAsync("review", %*{"code": "echo 1"})echo prompt.messages[0].contentlistResourcesAsync, listResourceTemplatesAsync, and listPromptsAsync
follow nextCursor until discovery is complete. readResourceAsync exposes
text and base64 blob contents, plus the server’s cache hints. Use
completePromptArgumentAsync or completeResourceArgumentAsync for argument
completion.
Subscriptions, input, and tasks
Section titled “Subscriptions, input, and tasks”Subscribe to server-side changes with a filter and read each wire notification from the returned stream:
let changes = await client.subscribeAsync(McpSubscriptionFilter( toolsListChanged: true))let (more, event) = await changes.read()if more: echo event["method"]await changes.closeAsync()Configure handlers for elicitation/create, sampling/createMessage, or
roots/list to let callToolAsync, getPromptAsync, and readResourceAsync
complete stateless input_required retries:
client.setElicitationHandler(proc (request: McpInputRequest): JsonNode = %*{"action": "accept", "content": {"confirmed": true}})Long-running tools return resultType == "task". Read their status with
getTask, provide pending answers with updateTask, or stop them with
cancelTask.
Manage the connection
Section titled “Manage the connection”A client owns a child process when using stdio, so it is not a short-lived temporary. HTTP connections are created per request. Two rules cover most cases:
- Keep it alive for as long as its tools are in use. The converted
Toolvalues close over the client, and closing it fails any in-flight request, so adefer: client.close()in the scope that owns the tools is the right shape. - Close it once.
closeis idempotent, stops the reader, and fails pending requests withMcpClientErrorrather than leaving futures unresolved.
If the server dies or closes its stdout, the reader notices and fails every pending request with that error. You do not get a hang.
Notes and limits
Section titled “Notes and limits”- No legacy handshake. nimgent supports the stateless
2026-07-28flow and does not fall back to the removed initialize/session protocol. - Tools are dynamic. Their schemas come from the server at connect time and are not checked at compile time. Validate what you care about, or keep the model’s instructions narrow.
outputSchemais parsed, not enforced.listToolsAsyncsurfaces it onMcpToolInfo, butasToolsAsynconly forwards the input schema to the provider; structured results arrive as text or asstructuredContent.- Server-initiated requests are in-band. Configure input handlers for elicitation, sampling, and roots. The client does not invent a legacy server-request channel.
- Windows is unverified. The non-blocking pipe setup is POSIX-only; on
Windows nimgent still launches the process (
poUsePath) but without that setup, so treat stdio MCP on Windows as untested.
Related: Tools and agents for the loop these tools run inside, and Error Handling for how a failing tool call reaches the model.