Skip to content

Orbit MCP

Exposes the agent tool registry as a remote MCP server so external AI clients — Claude (claude.ai, desktop, Claude Code), ChatGPT, Cursor, VS Code, any MCP-capable tool — can query a user's workspaces, and (since 2026-08-24) work the signal board as the connected person. It is the fourth consumer of the tool registry, alongside chat, autopilot, and the analysts: listTools() / getTool().handler({ workspaceId }, args) are already MCP wire shape, so the module is a transport + auth bridge, not a new tool system. A new module's agentTools appear in every connected client automatically — nothing to register here.

Plan and decision log: plans/orbit-mcp.md. User-facing setup guide: /docs → "Connect Orbit to your AI tools".

Surface

RouteAuthWhat
POST /api/modules/mcpAuthorization: Bearer orbit_mcp_… (OAuth-minted)The MCP endpoint (Streamable HTTP, stateless, JSON responses)
GET/DELETE /api/modules/mcp405 (stateless: no SSE stream, no session)
GET /api/modules/mcp/tokens · DELETE /tokens/:idrequireAuthConnection list + disconnect for the /account panel. No create endpoint — tokens are minted only by the OAuth exchange
POST /oauth/register · POST /oauth/tokenpublic, strict-limitedOAuth DCR + PKCE code exchange (see below)
GET /oauth/client/:id · POST /oauth/approverequireAuthThe consent page's read + approve calls
GET /.well-known/oauth-protected-resource[…] · /oauth-authorization-server[…]publicDiscovery metadata (mounted at the ORIGIN ROOT by createApp, not under the module)

Removed 2026-08-02 (once OAuth was proven live in production): manual key creation (POST /tokens + the panel's create/reveal flow) and the tokenized-URL variant (/t/:token). A secret inside a URL leaks through logs, browser history, and clipboards, and every mainstream MCP client speaks the OAuth flow — so the connect story is now exactly one URL + sign-in. Pre-existing manually-created tokens keep verifying until revoked (verifyToken can't tell them apart, by design).

Files: index.js (manifest — no settingsSchema; nothing here is per-workspace state) · routes.js (token auth + per-token rate limit + management + OAuth endpoints) · server.js (MCP SDK plumbing: per-request Server + StreamableHTTPServerTransport, instructions string, tools/list + tools/call handlers, track() audit per call) · tool-bridge.js (the actual bridge) · queries.js (tokens + the caller's workspace roster) · oauth.js (DCR + PKCE logic) · public.js (vanity-host rewrite + discovery metadata, imported by createApp like the pixel/lead-audit public routers).

Vanity host

MCP_HOSTS (mcp.orbit.luniq.io, mcp-staging.orbit.luniq.io) CNAME to the backend; rewriteMcpHost (createApp, next to rewritePixelHost) maps every path on those hosts except /.well-known/* into /api/modules/mcp/* — so customers save https://mcp.orbit.luniq.io/t/<key>, decoupled from the hosting provider. Discovery metadata is host-aware: the vanity origin describes the resource as its root, the raw backend origin as /api/modules/mcp. The /account panel hands out VITE_MCP_URL when set. Deploy runbook: plans/orbit-mcp.md.

OAuth 2.1 (one-click connect)

For clients that prefer a sign-in flow over a pasted key (claude.ai/ChatGPT connectors): bare-endpoint 401s carry WWW-Authenticate: Bearer resource_metadata=… → client reads the two /.well-known documents → registers itself (POST /oauth/register, RFC 7591, public clients only — PKCE S256, no secret, https-or-loopback redirect URIs) → sends the user to the frontend consent page /mcp/authorize (inline sign-in so the query params survive; approve calls POST /oauth/approve under Supabase auth) → exchanges the single-use, 10-minute, hash-stored code at POST /oauth/token.

The minted access token IS an mcp_tokens row labelled "<client> (OAuth)" — same per-call gates, visible and revocable in the /account panel like any manual key. No refresh tokens (tokens don't expire; revocation is the lifecycle, matching manual keys). Tables: mcp_oauth_clients, mcp_oauth_codes (both frozen, service-role only, RLS no-policies).

Auth model — account-scoped connections

One connection = the person, not a workspace. mcp_tokens (table name frozen) stores only a SHA-256 hash of the orbit_mcp_ bearer token the OAuth exchange mints; the raw value goes to the client once and is never shown in the app. Which workspaces a connection reaches is resolved live on every callworkspace_members + the isWorkspaceActive predicate (account approved AND workspace live) — never baked into the token, so membership changes take effect immediately. Revoked rows are kept (revoked_at). last_used_at bumps are throttled in-process to one write per token per minute.

Rate limits: IP-keyed expensiveLimiter on the route + an in-memory 60 calls/min per token.

The bridge (tool-bridge.js)

  • Workspace binding: every exposed tool's schema gets a workspace property injected (domain / name / id; required only when the account has >1 workspace), resolved against the roster and stripped before the handler runs with the usual ctx = { workspaceId }. A server-owned list_workspaces tool makes the roster discoverable — and since 2026-08-16 each LIVE workspace on it carries its automation state from modules/setup-state.js#getSetupState (scheduled_work on/off, off_because, setup { complete, missing[] }, measuring { search_console, pixel } — the SAME composition the app's setup banner and the chat's context block read) and its signals (2026-08-27: the cards present on its board — open + claimed — per severity {critical, warning, opportunity, info}, modules/agent/signals.js#countPresentSignals — the same numbers the agency home's tiles show as colours; null when the board read failed) and agency_site (2026-08-29: true on the ONE workspace that is the owning agency's own website — the included, seat-free slot accounts.agency_workspace_id), with the tool description + server instructions telling the client to read scheduled_work before interpreting missing/stale data: off means the weekly/nightly jobs are not refreshing that workspace, so an empty AI-visibility/backlinks/health result is "not measured since <date>", never a verdict; Search Console + pixel keep collecting. Ambiguous or non-member references come back as { error }.
  • Scope: registry tools filtered by the surfaces axis (2026-08-24): a tool may declare surfaces: ['chat'] or ['mcp'], absent = both; the one helper agent/tools/index.js#onSurface filters chat's listTools() (default surface 'chat'), the bridge's buildToolList (listTools('all', 'mcp')) and callTool's dispatch, so an mcp-only write tool can never leak into the chat loop and a chat-only tool is not callable here even by name. Reads are everything the registry has; the ONE write surface is the signal board (see below). Skills remain unreachable (state-changing, approval-gated — that flow does not map to MCP). MCP_TOOL_DENYLIST removes web_search, fetch_url, get_serp_state and get_keyword_validation — every one is a paid DataForSEO call per invocation with no real per-workspace cap on this path (the agent's per-turn cost caps don't run here; get_serp_state has only an in-process 30s cap, get_keyword_validation none), and the web pair adds SSRF surface — and get_skill_spec (2026-08-23): it returns the queue_action manual for the CHAT dispatcher, which is not on MCP, so over this surface it was dead weight that steered assistants toward actions they cannot take; it comes back only together with a write layer, never before. list_files.this_thread_only has no conversation to scope to over MCP and says so in a note rather than silently widening. The list is built with listTools('all') — the sentinel skips the CMS-capability gate, because the MCP list is account-wide and the workspace (and thus its CMS) is only known per call. With the old listTools(null), passesCapabilityGate returned false for null caps, so the first tool ever to declare requiresCmsCapability would have been permanently invisible over MCP (the latent parity bug from plans/tool-coverage-audit-2026-08-05.md). Gating still applies per call as before.
  • Self-describing: tool descriptions flow through verbatim (they are written for the model in the owning modules), and a tool may declare its own MCP annotations (the write tools set readOnlyHint: false, idempotentHint: false, destructiveHint: true on the edit pair) — absent, the bridge defaults to { readOnlyHint: true }. The server instructions string tells clients to call list_workspaces first, that failures return { error }, and (2026-08-24) carries the signals primer: what a signal is (issue card + document; "Notes" is the person's), the stage vocabulary (gone is never the assistant's to set), storyline = statusless grouping, the working loop (read → work in your environment → plan + in_progress → resolve with reason), claim-on-edit, "you act as the person; writes touch ONLY the signal board", and the daily write cap.
  • Cycle safety: the agent tools barrel is imported dynamically (fire-time). A static import would close modules/registry → mcp → … → agent/registry → modules/registry — the same hazard agent/manifest.js documents.
  • Gate-checked: the verify gate backend/scripts/check-tool-surface.mjs asserts the denylist only names tools that actually exist — a renamed tool would otherwise silently become MCP-exposed.

Signal writes (2026-08-24)

Three tools in modules/agent/tools/ declare surfaces: ['mcp'] — the connected assistant works the board as the person (they authorized the connection via OAuth), with no approval flow. Each is a thin wrapper over the same signals.js / board/lifecycle.js functions the board's routes and the chat skills call, so every board rule holds by construction, and chat NEVER lists them (there the same verbs are the approval-gated update_signal / edit_signal_document / update_board skills — see the safety test's approval-bypass check).

ToolWrapsRules it inherits
create_signalcreateSignaltitle required; sections build the document via replaceSections (editable headings only — "Notes" refused at the schema AND mechanically); statusCREATE_STATUSES (default planned, never a close); born owned (manual/manual)
edit_signalsetSignalStatus + saveSignalEditsstage move FIRST, then edits (the board PATCH's order, so reopen + edit works in one call); any human-legal stage; closes land through closeItem under the mcp closer (board/lifecycle.js — person-class, final, modeled on chat; labeled "closed by a connected assistant" in SignalDetail.tsx's CLOSER_LABEL and get_signals' closer legend); optional reason ≤300 → evidence.closed_reason; edit-claims-open-card → planned, stated in the response
edit_storylineupsertStoryline (actor 'human'), addStorylineMembers, removeStorylineMember, disbandStoryline, dismissNarrative (closer 'mcp')actions create/update/add_members/remove_member/disband/dismiss — the update_board skill's exact rules: body_md ≤40k and never alters ## Notes (touchesNotes), play steps stripped to { text } (no how from this surface), dismiss closes every member as Dismissed + writes the group memory

Write budget: WRITE_BUDGET_PER_DAY = 200 write-tool dispatches per token per UTC day — an in-process Map in tool-bridge.js keyed (tokenId, UTC day) (per-process by design: a single API process serves MCP today; sharding moves it to a table). Over cap → { error: 'daily write budget reached (200/day) — resumes at UTC midnight' }. The token id threads routes.js#requireMcpToken → handleMcpRequest → callTool.

Prompts (prompts.js)

The MCP prompts primitive: PROMPTS = [{ name, description, arguments?, text() }], served by prompts/list / prompts/get in server.js (capability prompts: {} declared at initialize). Three working recipes for the connected model: work_the_board (triage the open column → pick a card matching your abilities → work it end to end → resolve with evidence), weekly_review (performance deltas + board movement + outcomes as a briefing), diagnose_page (argument url; chains item details + performance history + page health + backlinks + board history for that URL). Adding a prompt = adding an entry to the array.

Observability

Every tools/call lands in api_events via track({ provider: 'mcp', operation: 'tools/call:<name>', userId, workspaceId, status, latencyMs }).