@nightowlsdev/mcp-server
Adapter/ToolingExpose 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-serverKey exports
- createSwarmMcpServer
- runSwarmMcpStdio
- types: SwarmMcpServerOpts, SwarmMcpAgent, SwarmMcpContext
Usage
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 stdioWhat 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.
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.
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. StreamableHTTPServerTransportDoing 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
- mcp — The inverse direction: consume external MCP servers' tools inside your swarm.
- core — SwarmEngine, StorageAdapter, and the SwarmEvent stream this server drains into a result.
- runner-nextjs — The HTTP/SSE runner to reach for when the caller is a browser chat UI instead of an MCP client.
- storage-supabase — The durable StorageAdapter that lets a needs_input followupId survive a restart.
- agent-audiences — How audience reachability gates which agents an MCP caller can address.