Skip to content

Bridging an MCP server

The mcp module connects to Model Context Protocol servers and exposes their tools to the model as user-defined tools. require('mcp') yields the module; mcp.connect_stdio launches a server as a child process and talks to it over its stdin/stdout, while mcp.connect_http reaches one over streamable HTTP. Each returns an McpClient handle whose list_tools enumerates what the server advertises (each already shaped for registry:add) and whose call dispatches a tool.

Both discovery and reconfiguration run once per config load (at startup and on each /reload), with reconfigure firing first. So connect the server in a reconfigure handler and keep the handle in a global; the later discover_tools handler then reads that handle and registers the server's tools. The handle's connected method reports whether the session is still live, so the tools can stop being offered (with a nudge to /reload) once a stdio server exits, since nothing respawns it:

-- mcp_time.lua -- expose the mcp-server-time MCP server's tools to the model.
--
-- Drop this beside your config.lua and `require('mcp_time')`.

local mcp = require 'mcp'

-- `reconfigure` runs before `discover_tools`, so connect here and stash the
-- handle and tool list in globals for the discovery handler to read.
global mcp_client
global mcp_tools

agent.on('reconfigure', function()
  -- pcall so a missing or broken server just disables these tools rather than
  -- aborting the whole config load.
  local ok, client = pcall(mcp.connect_stdio, {
    command = { 'uvx', 'mcp-server-time' },
  })
  if not ok then
    print('mcp: time server unavailable: ' .. tostring(client))
    mcp_client, mcp_tools = nil, nil
    return
  end
  -- Reassigning drops any handle left from a previous /reload; its stdio child
  -- is killed as the old session closes.
  mcp_client = client
  mcp_tools = client:list_tools()
end)

agent.on('discover_tools', function(registry)
  if not mcp_client then return end

  -- A stdio server that has died stays non-nil but closed: nothing respawns it.
  -- Stop offering its tools and tell the user how to recover.
  if not mcp_client:connected() then
    print('mcp: time server has exited; run /reload to reconnect')
    return
  end

  for _, tool in ipairs(mcp_tools) do
    registry:add {
      name = 'time__' .. tool.name,
      description = tool.description,
      parameters = tool.parameters,
      run = function(args, ctx)
        return mcp_client:call(tool.name, args)
      end,
    }
  end
end)

A tool's result comes back as { content, is_error }, which is exactly what a run callback may return, so a server-reported failure surfaces to the model instead of raising; only a transport or protocol failure raises. For several servers, make the globals a list of { prefix, handle, tools } and loop the same way, namespacing each server's tools by its prefix.

For a server reached over HTTP the shape is the same, only the connect_* call changes. For example, DeepWiki is a free, public, no-authentication server whose tools answer questions about GitHub repositories:

local ok, client = pcall(mcp.connect_http, {
  url = 'https://mcp.deepwiki.com/mcp',
})

A server behind a token takes an Authorization header (and any others) via headers = { Authorization = 'Bearer ' .. token }.

An HTTP connection recovers on its own: it replays the handshake when the server reports an expired session, and reconnects a dropped event stream with exponential backoff (indefinitely by default). Pass max_retries to connect_http to bound that so a server that stays down eventually surfaces a failure rather than retrying forever. A stdio child has no such recovery, so /reload is the way back.