Skip to content

0010. Run assistant tools in-process instead of through our MCP server

Context

Monark already exposes itself to external AI agents (Claude Desktop, Cursor) through @monark/mcp, a stdio MCP server that authenticates with a static mrk_ API key and calls the public /api/v1 surface. The app-owned assistant needs tools too, and pointing it at that same server is the obvious shortcut.

Three forces push against it. The assistant must act as the signed-in user, so permissions, per-record access and the audit trail name the real person; an MCP connection acts as one fixed key holder. MCP here is stdio-only, so calling it from the api would mean spawning a process, or building an HTTP transport that doesn't exist, for the server to call itself. And MCP only sees routes marked for exposure, while the assistant also needs surfaces kept off the public API on purpose, such as automations.

The part worth sharing with MCP is the tool definitions, not the transport.

Options

OptionForAgainst
Call our own MCP serverOne integration path for every agentActs as a key holder, not the user, unless we mint a key per user; needs a process spawn or a new transport; limited to exposed routes
Hand-write a separate tool set for the assistantFull control over each toolTwo definitions of every action that drift apart; a second authorization path to keep correct
Build tools from the public API route descriptors and run them through an in-process callerOne definition per action; runs as the user; no HTTP hop or API key; permissions, events and validation reused as-isTied to the api process; tools outside the public API need their own small definitions

Decision

Build the assistant's tools from the same V1_ROUTES descriptors that drive the MCP server, and run them in-process through a server-side tRPC caller created from the user's session, the pattern the public-API facade already uses. Surfaces kept off the public API get in-app-only tool definitions that call the same caller.

The deciding reason is identity: running as the session user means the assistant needs no authorization logic of its own.

V1_ROUTES --> MCP server ------------> external agents (stdio, API key)
          \-> buildChatToolset() ----> in-app assistant (in-process, as the user)

Consequences

  • A route is the single source of truth for both external and in-app tools. Marking a route for MCP exposure also gives it to the assistant, so each exposure decision has to suit both audiences.
  • Each tool is gated by its procedure's own permission. A user can never do more through the assistant than in the UI.
  • Writes are identified from the route's HTTP method (anything but GET), which drives the confirm-each-write gate.
  • The assistant reaches nothing by default: only exposed routes plus the in-app tools, each added on purpose.
  • In-app-only tools (automations, wiki, navigation) live in services/api/src/chat/ and must be kept in step with their routers by hand.
  • The assistant can only run inside the api process, which owns the router; a long turn holds an open streaming connection for its whole length. How the chat package gets the executor without importing the api is a separate decision: Inject the assistant's tool executor at boot.