Skip to content
Night Owls.dev
Jump to a page

@nightowlsdev/mcp-server

Adapter/Tooling

Expose a Night Owls swarm AS an MCP server, ask-only `ask_<agent>` tools, no workflow execution.

What it does

@nightowlsdev/mcp-server turns a running Night Owls swarm into an MCP server so other agents/tools can call your agents. createSwarmMcpServer takes the SwarmEngine, a StorageAdapter, the list of agents to expose, and a resolveContext function that derives the caller's tenant/user identity from the MCP request (identity comes only from the transport, never from attacker-controllable tool args). Each agent becomes a single ask_<slug> tool; calling it runs the agent and drains the event stream to one JSON result (done with the answer, needs_input for a durably-suspended HITL ask that you answer via a follow-up call with the followupId, or failed). runSwarmMcpStdio wires it over stdio. By the engine-wall contract it imports only the raw MCP SDK plus @nightowlsdev/core TYPES, never the engine vendor, and exposes ONLY ask_<agent> tools, never run_<workflow>.

Install

pnpm add @nightowlsdev/mcp-server

Key exports

  • createSwarmMcpServer
  • runSwarmMcpStdio
  • types: SwarmMcpServerOpts, SwarmMcpAgent, SwarmMcpContext

Usage

mcp-server.ts
import { createSwarmMcpServer, runSwarmMcpStdio } from "@nightowlsdev/mcp-server";

const server = createSwarmMcpServer({
  engine,
  storage,
  agents: [{ slug: "editor" }], // becomes the ask_editor tool
  resolveContext: (req) => ({ tenantId: req.tenantId, userId: req.userId }),
});

await runSwarmMcpStdio(server); // expose over stdio

What it provides

mcp-server turns a running Night Owls swarm INTO an MCP server, so other agents, coding tools, and services can call your agents. `createSwarmMcpServer` takes the SwarmEngine, a StorageAdapter, the agents to expose, and a resolveContext that derives the caller's identity from the transport; each agent becomes a single `ask_<slug>` tool. Calling it runs the agent and drains the event stream to one JSON result — done with the answer, needs_input for a durably-suspended HITL question, or failed. `runSwarmMcpStdio` wires the same server over stdio in one call.

When to use it

  • You want to expose your agents to an external MCP client — Claude Code, Cursor, another agent runtime, or a service — as callable tools.
  • You want other systems to reach an agent's answer through a stable ask_<slug> contract without embedding your engine, keys, or governance in the caller.
  • You need multi-turn human-in-the-loop to survive across separate MCP calls: a needs_input result carries a durable followupId the caller answers later.
  • You want identity taken strictly from the transport (auth headers / authInfo), never from attacker-controllable tool arguments.

When not to

  • Your consumer is a browser chat UI — use @nightowlsdev/runner-nextjs, which streams SSE token-by-token; mcp-server returns one JSON result per call, not a live stream.
  • You want to CONSUME external MCP tools inside your swarm — that is the inverse package, @nightowlsdev/mcp.
  • You need to trigger workflow execution remotely — by contract this exposes ONLY ask_<agent> tools, never run_<workflow>.

Alternatives

  • @nightowlsdev/runner-nextjsThe caller is a web client. It serves the run over HTTP/SSE with live streaming and pairs with @nightowlsdev/react's chat UI. mcp-server is for MCP callers wanting a single structured result.
  • @nightowlsdev/mcpYou want the opposite direction — pull external MCP servers' tools INTO your swarm as approval-gated SwarmTools.

Strengths

  • One line to stdio: runSwarmMcpStdio wires the server to a StdioServerTransport with all logs on stderr and JSON-RPC on stdout.
  • Identity is transport-only: resolveContext reads authInfo/headers and can return null to reject — tool arguments can never assert tenancy, which closes a whole class of confused-deputy attacks.
  • Durable HITL across calls: a suspended question returns { status: 'needs_input', followupId }; the caller resumes by calling the same tool again with followupId + answer.
  • Every failure path — including engine throws before the first event — returns structured JSON in content[0].text, so callers can always parse the result rather than catching raw exceptions.
  • Engine-wall clean: imports only the raw @modelcontextprotocol/sdk and @nightowlsdev/core TYPES — no engine vendor leaks into the public API.

Limits & trade-offs

  • No streaming to the caller: each ask_<slug> call drains the whole run to one JSON object, so a long agent turn returns nothing until it finishes (or suspends).
  • needs_input resume needs durable storage: the followupId is a durable suspend token, so an in-memory StorageAdapter loses the suspended run across a process restart — pair it with storage-supabase for real HITL.
  • Ask-only surface: agents are reachable, workflows are not — there is no run_<workflow> tool by design.
  • The caller MUST NOT discard a followupId: without it the suspended run cannot be resumed and is orphaned; the `to` field on a question is advisory and the MCP host is always the sole resumer.

How it works

createSwarmMcpServer builds an McpServer (from the MCP SDK) pre-loaded with one ask_<slug> tool per agent in opts.agents; it is not yet connected — you call server.connect(transport) with your chosen transport (e.g. StreamableHTTPServerTransport), or use runSwarmMcpStdio for stdio. On each call the handler runs resolveContext(extra) against the per-request transport/auth info; returning null rejects the call as unauthorized. For a fresh ask it runs the agent and drains the SwarmEvent stream to a single result: done (with the answer + runId), needs_input (the question + a durable followupId + any partial text, with the run left suspended), or failed (the engine's failure stage). A resume call supplies followupId + answer instead of message, looking the suspended run up in storage and re-entering it. When the context carries audiences (FR-031) or orgScope (FR-055), both the fresh-ask and resume paths enforce reachability before any side effect, so a caller that cannot reach a targeted agent gets AudienceForbidden.

Examples

Expose a swarm over stdio

Each agent slug becomes an ask_<slug> tool. Identity comes from resolveContext, never from tool args.

mcp-server-example-1.ts
import { runSwarmMcpStdio } from "@nightowlsdev/mcp-server";
import { SwarmEngine, InMemoryStorage } from "@nightowlsdev/core";
import type { SwarmMcpContext } from "@nightowlsdev/mcp-server";

const storage = new InMemoryStorage();
const engine = new SwarmEngine({ storage, modelFactory, cost });

await runSwarmMcpStdio({
  engine,
  storage,
  agents: [
    { slug: "biller", name: "Billing agent", role: "handles refunds and invoices" },
    { slug: "support" },
  ],
  // stdio: a fixed context. Over HTTP, read extra.authInfo / headers — never tool args.
  resolveContext: (): SwarmMcpContext => ({ tenantId: "acme", userId: "u-123" }),
});
// Process stays alive; connect an MCP client to its stdin/stdout.

HTTP server with token-derived identity

resolveContext returns null to reject; return a context to admit. Wire your own transport.

mcp-server-example-2.ts
import { createSwarmMcpServer } from "@nightowlsdev/mcp-server";

const server = createSwarmMcpServer({
  engine,
  storage,
  agents: await engine.listAgents(ctx), // AgentSummary[] — pass directly
  resolveContext: async (extra) => {
    const token = (extra as { authInfo?: { token: string } }).authInfo?.token;
    if (!token) return null; // reject as unauthorized
    return { tenantId: "acme", userId: await verifyToken(token) };
  },
});

await server.connect(transport); // e.g. StreamableHTTPServerTransport

Doing the parts it doesn't support

  • Streaming an agent's answer to the callerNot supported — ask_<slug> returns one JSON result per call. If you need live token streaming to a client, serve the same swarm through @nightowlsdev/runner-nextjs (HTTP/SSE) and render it with @nightowlsdev/react.
  • Running a workflow remotelyOut of scope by contract — this exposes only ask_<agent>, never run_<workflow>. Drive workflows through your own runner; keep the MCP surface to agent asks.
  • HITL that survives a restartThe needs_input followupId is durable only if storage is. Back the server with @nightowlsdev/storage-supabase so a suspended run and its followupId survive a process death and the caller can answer later.

Related

  • mcpThe inverse direction: consume external MCP servers' tools inside your swarm.
  • coreSwarmEngine, StorageAdapter, and the SwarmEvent stream this server drains into a result.
  • runner-nextjsThe HTTP/SSE runner to reach for when the caller is a browser chat UI instead of an MCP client.
  • storage-supabaseThe durable StorageAdapter that lets a needs_input followupId survive a restart.
  • agent-audiencesHow audience reachability gates which agents an MCP caller can address.