Appearance
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
| Route | Auth | What |
|---|---|---|
POST /api/modules/mcp | Authorization: Bearer orbit_mcp_… (OAuth-minted) | The MCP endpoint (Streamable HTTP, stateless, JSON responses) |
GET/DELETE /api/modules/mcp | — | 405 (stateless: no SSE stream, no session) |
GET /api/modules/mcp/tokens · DELETE /tokens/:id | requireAuth | Connection list + disconnect for the /account panel. No create endpoint — tokens are minted only by the OAuth exchange |
POST /oauth/register · POST /oauth/token | public, strict-limited | OAuth DCR + PKCE code exchange (see below) |
GET /oauth/client/:id · POST /oauth/approve | requireAuth | The consent page's read + approve calls |
GET /.well-known/oauth-protected-resource[…] · /oauth-authorization-server[…] | public | Discovery 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 (verifyTokencan'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 call — workspace_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
workspaceproperty injected (domain / name / id; required only when the account has >1 workspace), resolved against the roster and stripped before the handler runs with the usualctx = { workspaceId }. A server-ownedlist_workspacestool makes the roster discoverable — and since 2026-08-16 each LIVE workspace on it carries its automation state frommodules/setup-state.js#getSetupState(scheduled_workon/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 itssignals(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;nullwhen the board read failed) andagency_site(2026-08-29: true on the ONE workspace that is the owning agency's own website — the included, seat-free slotaccounts.agency_workspace_id), with the tool description + serverinstructionstelling the client to readscheduled_workbefore 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
surfacesaxis (2026-08-24): a tool may declaresurfaces: ['chat']or['mcp'], absent = both; the one helperagent/tools/index.js#onSurfacefilters chat'slistTools()(default surface'chat'), the bridge'sbuildToolList(listTools('all', 'mcp')) andcallTool'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_DENYLISTremovesweb_search,fetch_url,get_serp_stateandget_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_statehas only an in-process 30s cap,get_keyword_validationnone), and the web pair adds SSRF surface — andget_skill_spec(2026-08-23): it returns thequeue_actionmanual 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_onlyhas no conversation to scope to over MCP and says so in anoterather than silently widening. The list is built withlistTools('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 oldlistTools(null),passesCapabilityGatereturned false for null caps, so the first tool ever to declarerequiresCmsCapabilitywould have been permanently invisible over MCP (the latent parity bug fromplans/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 setreadOnlyHint: false,idempotentHint: false,destructiveHint: trueon the edit pair) — absent, the bridge defaults to{ readOnlyHint: true }. The serverinstructionsstring tells clients to calllist_workspacesfirst, 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 (goneis 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.jsdocuments. - Gate-checked: the verify gate
backend/scripts/check-tool-surface.mjsasserts 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).
| Tool | Wraps | Rules it inherits |
|---|---|---|
create_signal | createSignal | title required; sections build the document via replaceSections (editable headings only — "Notes" refused at the schema AND mechanically); status ∈ CREATE_STATUSES (default planned, never a close); born owned (manual/manual) |
edit_signal | setSignalStatus + saveSignalEdits | stage 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_storyline | upsertStoryline (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 }).