@orboto/mcp 0.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,608 @@
1
+ /**
2
+ * ORB-244 Phase A - Streamable HTTP transport for the MCP server.
3
+ *
4
+ * Used by the Self-Hosted-inline delivery variant: a separate
5
+ * container alongside the API, listening on `ORBOTO_MCP_PORT` (default
6
+ * 3100). The reverse proxy routes `/mcp` to this port.
7
+ *
8
+ * Per-request auth: every POST must carry `Authorization: Bearer
9
+ * orb_*` in the header. We build one `McpServer` per session so each
10
+ * session gets its own OrbotoClient bound to that session's token -
11
+ * no mid-session token mutation, no cross-session leakage.
12
+ *
13
+ * Sessions are indexed by the MCP-spec-mandated `mcp-session-id`
14
+ * header the transport generates on initialise. Closing the session
15
+ * deletes the transport; a fresh initialise spins up a new one.
16
+ *
17
+ * ORB-1353 - session resilience. The in-memory session map is wiped on every
18
+ * api restart/deploy, which used to invalidate every connected client at once
19
+ * (a real ZCode adapter then dead-looped on the resulting 404 for hours). Two
20
+ * server-side layers fix this: (1) the session registry is PERSISTED to the db
21
+ * via `/system/mcp/sessions`, so a request carrying a session id we no longer
22
+ * hold in memory is rehydrated under the SAME id after a restart; (2) an
23
+ * unknown session id under otherwise-valid auth is transparently AUTO-ADOPTED
24
+ * under a fresh id (MCP tool calls are stateless per-call, so re-establishing
25
+ * loses nothing). The ORB-1324 404 re-initialise contract stays ONLY for
26
+ * requests without valid auth; the kill-switch + mcp:use preflight still fires
27
+ * on every (re)hydration.
28
+ *
29
+ * No Express dep - Node's built-in http server is enough for a
30
+ * single-route MCP endpoint and keeps the production image small.
31
+ */
32
+ import { createServer } from 'node:http';
33
+ import { randomUUID } from 'node:crypto';
34
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
35
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
36
+ import { buildOrbotoMcpServer } from './server.js';
37
+ import { resolveToolset } from './toolset.js';
38
+ import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
39
+ import { EventBridge } from './event-bridge.js';
40
+ /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
41
+ * call builds a short-lived OrbotoClient bound to the request's token so the
42
+ * api scopes every read/write to that identity. */
43
+ export function createApiSessionStore(baseUrl) {
44
+ return {
45
+ async register(token, meta) {
46
+ const client = new OrbotoClient({ baseUrl, apiKey: token });
47
+ await client.post('/system/mcp/sessions', meta);
48
+ },
49
+ async resolve(token, sessionId) {
50
+ const client = new OrbotoClient({ baseUrl, apiKey: token });
51
+ const res = await client.get(`/system/mcp/sessions/${encodeURIComponent(sessionId)}`);
52
+ return res.found === true;
53
+ },
54
+ async remove(token, sessionId) {
55
+ const client = new OrbotoClient({ baseUrl, apiKey: token });
56
+ await client.delete(`/system/mcp/sessions/${encodeURIComponent(sessionId)}`);
57
+ },
58
+ };
59
+ }
60
+ /** Wrap a mutable token holder as an OAuthTokenProviderLike so the session's
61
+ * clients resolve the latest presented bearer per request. `forceRefresh`
62
+ * cannot mint a new token here (the CLIENT owns the OAuth refresh), so on a
63
+ * 401 it just re-reads the holder; the client presents its refreshed bearer
64
+ * on the next request, which updates the holder for subsequent calls. */
65
+ function holderTokenProvider(holder) {
66
+ return {
67
+ getAccessToken: async () => holder.current,
68
+ forceRefresh: async () => holder.current,
69
+ };
70
+ }
71
+ export function classifyUnknownSession(input) {
72
+ if (!input.hasValidAuth)
73
+ return 'reinit-404';
74
+ return input.isPersistedForCaller ? 'rehydrate' : 'adopt';
75
+ }
76
+ function forceInitialized(transport, sessionId) {
77
+ const web = transport._webStandardTransport;
78
+ web.sessionId = sessionId;
79
+ web._initialized = true;
80
+ }
81
+ /** Rewrite the `mcp-session-id` on an in-flight Node request so the transport
82
+ * validates it against an adopted (fresh) id. The SDK's transport builds its
83
+ * Web Request from `rawHeaders`, so patching the parsed `headers` object alone
84
+ * is not enough - both must be updated (ORB-1353). */
85
+ function overrideSessionIdHeader(req, sessionId) {
86
+ req.headers['mcp-session-id'] = sessionId;
87
+ const raw = req.rawHeaders;
88
+ let found = false;
89
+ for (let i = 0; i < raw.length; i += 2) {
90
+ if (raw[i]?.toLowerCase() === 'mcp-session-id') {
91
+ raw[i + 1] = sessionId;
92
+ found = true;
93
+ }
94
+ }
95
+ if (!found)
96
+ raw.push('mcp-session-id', sessionId);
97
+ }
98
+ /** Idle-touch throttle: don't re-persist a live session more than once per
99
+ * minute of activity. The api slides the row's TTL on every touch. */
100
+ const TOUCH_THROTTLE_MS = 60_000;
101
+ /** Extract a "name@version" label from an initialize request's clientInfo, for
102
+ * observability of which adapter owns a session. Returns undefined when the
103
+ * body carries no usable clientInfo. */
104
+ export function clientInfoLabel(body) {
105
+ const params = body?.params;
106
+ const ci = params?.clientInfo;
107
+ if (ci && typeof ci.name === 'string' && ci.name.length > 0) {
108
+ return typeof ci.version === 'string' && ci.version.length > 0 ? `${ci.name}@${ci.version}` : ci.name;
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * ORB-941 - graceful close of every active MCP session when the
114
+ * workspace kill-switch (`system_config.mcp_enabled`) flips to disabled.
115
+ *
116
+ * The MCP spec has no standard `notifications/server/closing` method, so
117
+ * the closest correct behaviour is: emit a best-effort logging
118
+ * notification (visible to clients that negotiated the logging
119
+ * capability) explaining WHY, then close the transport. Closing the
120
+ * transport ends the SSE stream; the client's next request lands on the
121
+ * unknown-session 404 branch, which per the Streamable-HTTP spec
122
+ * triggers a transparent re-initialise - and our per-session preflight
123
+ * then refuses that re-init with the clear "administrator has disabled
124
+ * MCP access" error. So an in-flight session is dropped promptly and the
125
+ * client gets an actionable reason rather than an opaque hang.
126
+ *
127
+ * Exported for unit testing with fake sessions.
128
+ */
129
+ export async function closeAllMcpSessions(sessions, reason) {
130
+ let closed = 0;
131
+ for (const { transport, mcp } of [...sessions]) {
132
+ try {
133
+ await mcp.server.sendLoggingMessage({
134
+ level: 'warning',
135
+ data: `orboto MCP: ${reason} Closing this session.`,
136
+ });
137
+ }
138
+ catch {
139
+ // Client never negotiated the logging capability - skip the
140
+ // notice; the transport close below is what actually enforces it.
141
+ }
142
+ try {
143
+ await transport.close();
144
+ }
145
+ catch {
146
+ // Already closing / closed - nothing to do.
147
+ }
148
+ closed++;
149
+ }
150
+ return closed;
151
+ }
152
+ /** Read the request body as JSON. Fails hard on empty body for POSTs
153
+ * that need one; MCP clients always send a body on /mcp.
154
+ * ORB-1576 - byte cap: the auth check runs AFTER the body is buffered,
155
+ * so an unauthenticated client could otherwise grow container memory
156
+ * with an endless POST. JSON-RPC envelopes are small (the fat
157
+ * attachment payloads ride the api directly, not /mcp). */
158
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
159
+ async function readJsonBody(req) {
160
+ return new Promise((resolve, reject) => {
161
+ const chunks = [];
162
+ let received = 0;
163
+ req.on('data', (chunk) => {
164
+ received += chunk.length;
165
+ if (received > MAX_BODY_BYTES) {
166
+ req.destroy();
167
+ reject(new Error(`request body too large (>${MAX_BODY_BYTES} bytes)`));
168
+ return;
169
+ }
170
+ chunks.push(chunk);
171
+ });
172
+ req.on('end', () => {
173
+ const text = Buffer.concat(chunks).toString('utf8');
174
+ if (text.length === 0)
175
+ return resolve(null);
176
+ try {
177
+ resolve(JSON.parse(text));
178
+ }
179
+ catch (err) {
180
+ reject(err);
181
+ }
182
+ });
183
+ req.on('error', reject);
184
+ });
185
+ }
186
+ function sendError(res, status, message, extraHeaders) {
187
+ res.writeHead(status, { 'content-type': 'application/json', ...(extraHeaders ?? {}) });
188
+ res.end(JSON.stringify({ error: message }));
189
+ }
190
+ /** ORB-957 - RFC 6750 §3 WWW-Authenticate challenge for /mcp 401s.
191
+ * MCP-aware clients (Claude Desktop, Cursor, VS Code Copilot) follow
192
+ * the resource_metadata URL to auto-discover the OAuth flow.
193
+ *
194
+ * The resource_metadata URL must point at the PUBLIC host the AI
195
+ * client can reach - NOT the internal `http://api:3000` baseUrl the
196
+ * MCP container uses to talk to the API. We derive the public URL
197
+ * from the incoming request's Host + X-Forwarded-Proto headers,
198
+ * which the reverse proxy (nginx → web container) sets for us.
199
+ * Falls back to baseUrl only when the headers are missing (local
200
+ * dev / direct connections).
201
+ */
202
+ function wwwAuthChallenge(req, baseUrl, error, description) {
203
+ const host = req.headers['x-forwarded-host']
204
+ || req.headers.host;
205
+ // Multi-layer proxies (Coolify's outer Caddy → web container's nginx
206
+ // → mcp container) sometimes lose the original scheme. Trust an
207
+ // explicit X-Forwarded-Proto header if present; otherwise default to
208
+ // https UNLESS the host is obviously localhost / a private IP (local
209
+ // dev). The MCP container only runs in TLS-terminated deployments,
210
+ // so https-by-default is right for production + lets local dev
211
+ // explicitly send X-Forwarded-Proto=http if needed.
212
+ const hostIsLocal = !!host && /^(localhost|127\.|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
213
+ const protoHeader = req.headers['x-forwarded-proto'];
214
+ const proto = protoHeader || (hostIsLocal ? 'http' : 'https');
215
+ const origin = host
216
+ ? `${proto}://${host}`
217
+ : baseUrl.replace(/\/$/, '');
218
+ const resourceMetadata = `${origin}/.well-known/oauth-protected-resource`;
219
+ return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
220
+ }
221
+ export function createHttpServer({ baseUrl, sessionStore }) {
222
+ // One transport + server per MCP session. The session-id comes from
223
+ // the transport's `onsessioninitialized` hook, which fires after
224
+ // the `initialize` request lands and we've minted a fresh id.
225
+ const sessions = new Map();
226
+ // ORB-1353 - persisted-session store (survives api restarts). Defaults to
227
+ // the api-backed store; tests inject a fake.
228
+ const store = sessionStore ?? createApiSessionStore(baseUrl);
229
+ // Build the per-session core (client + subscription set + MCP server +
230
+ // event bridge) bound to a token. Shared by the new-session, rehydrate, and
231
+ // adopt paths so they can't drift apart.
232
+ async function buildSessionCore(tokenHolder, userAgentSuffix, toolset) {
233
+ // ORB-1470 - bind every long-lived per-session consumer (kill-switch
234
+ // client, tool-handler client, SSE bridge) to the SAME mutable holder so
235
+ // one `holder.current = <request bearer>` update rotates the bearer for
236
+ // all of them at once.
237
+ const tokenProvider = holderTokenProvider(tokenHolder);
238
+ const sessionClient = new OrbotoClient({ baseUrl, tokenProvider, userAgentSuffix });
239
+ // ORB-940 - per-session subscription set + live-event bridge. The set is
240
+ // mutated by resources/subscribe + resources/unsubscribe handlers inside
241
+ // the McpServer; the bridge reads it to decide which incoming API events
242
+ // deserve a push.
243
+ //
244
+ // ORB-1353 subscription boundary: a rehydrated (layer 1) or adopted
245
+ // (layer 2) session ALWAYS starts with an empty subscription set + a fresh
246
+ // bridge. Live subscription state is per-session in-memory state tied to a
247
+ // now-dead SSE socket, so it cannot be carried across an api restart even
248
+ // for the same session id - per the MCP spec, re-subscribing after a
249
+ // session change is the client's job. Rehydrating under the SAME id keeps
250
+ // the deploy case coherent (the client's own session bookkeeping stays
251
+ // valid and its re-subscribe resumes pushes); adoption gives a new id so
252
+ // the client treats it as a fresh session and re-subscribes from scratch.
253
+ const subscriptions = new Set();
254
+ const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
255
+ const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
256
+ return { sessionClient, subscriptions, mcp, bridge };
257
+ }
258
+ // Rehydrate/adopt: stand up a session bound to `token` and force it into the
259
+ // initialized state under `chosenSessionId` WITHOUT replaying the initialize
260
+ // handshake, so the client's in-flight non-init request validates against it.
261
+ /** ORB-1576 - resolve a bearer's owner identity via the status preflight;
262
+ * null on any failure (invalid token, MCP off, network) = fail closed. */
263
+ async function resolveUserEmail(token) {
264
+ try {
265
+ const client = new OrbotoClient({ baseUrl, apiKey: token });
266
+ const { userEmail } = await preflightMcpSession(client);
267
+ return userEmail;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
274
+ const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
275
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
276
+ transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
277
+ await mcp.connect(transport);
278
+ forceInitialized(transport, chosenSessionId);
279
+ const session = {
280
+ transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail, lastTouchAt: Date.now(),
281
+ };
282
+ sessions.set(chosenSessionId, session);
283
+ bridge.start();
284
+ return session;
285
+ }
286
+ // ORB-941 / ORB-942 - MCP kill-switch poll. Two kill-switches ride the
287
+ // same `/system/mcp/status` probe while sessions are live:
288
+ // - workspace-wide (`system_config.mcp_enabled`, ORB-941): an admin
289
+ // flip closes EVERY in-flight session (any session's response is
290
+ // authoritative for the whole workspace).
291
+ // - per-user (`users.mcp_enabled`, ORB-942): a user flipping their own
292
+ // opt-out closes only THAT user's sessions. Since the flag is
293
+ // per-identity, each session is probed with its own token and the
294
+ // `userMcpEnabled` field is read from the same response.
295
+ // New sessions (and rehydrate/adopt) are already refused at preflight, so
296
+ // the poll only needs to reap existing sessions. Only polls while sessions
297
+ // exist → zero cost when idle. Interval is env-tunable for tests.
298
+ const pollMs = Number(process.env.ORBOTO_MCP_KILLSWITCH_POLL_MS ?? 30_000);
299
+ async function pollKillSwitch() {
300
+ if (sessions.size === 0)
301
+ return;
302
+ const userDisabled = [];
303
+ for (const session of sessions.values()) {
304
+ try {
305
+ const status = await session.client.get('/system/mcp/status');
306
+ if (!status.enabled) {
307
+ // Workspace-wide off wins and is authoritative for everyone -
308
+ // close all sessions and stop probing.
309
+ await closeAllMcpSessions(sessions.values(), 'the workspace administrator has disabled MCP access.');
310
+ return;
311
+ }
312
+ if (!status.userMcpEnabled)
313
+ userDisabled.push(session);
314
+ }
315
+ catch {
316
+ // This session's token may be expired/invalid - try the next.
317
+ }
318
+ }
319
+ if (userDisabled.length > 0) {
320
+ await closeAllMcpSessions(userDisabled, 'you have disabled MCP access for your account.');
321
+ }
322
+ }
323
+ const killSwitchTimer = setInterval(() => { void pollKillSwitch(); }, pollMs);
324
+ // Don't let the poll keep the process (or a test) alive on its own.
325
+ killSwitchTimer.unref?.();
326
+ const server = createServer(async (req, res) => {
327
+ // Health probe - stays cheap, zero-auth so docker healthchecks
328
+ // don't need a token. Doesn't reveal anything about config.
329
+ if (req.method === 'GET' && req.url === '/health') {
330
+ res.writeHead(200, { 'content-type': 'application/json' });
331
+ res.end(JSON.stringify({ status: 'ok' }));
332
+ return;
333
+ }
334
+ // Route: all MCP traffic lands on /mcp per spec convention.
335
+ // Anything else is 404 so an accidental probe at / doesn't leak
336
+ // the transport. ORB-1520 - a query string is allowed (and used):
337
+ // `?toolset=full` opts a connection into the full named-tool
338
+ // manifest without server-side env access. The param rides on EVERY
339
+ // request from a client configured with that URL, so it needs no
340
+ // session persistence.
341
+ const requestUrl = new URL(req.url ?? '', 'http://localhost');
342
+ if (requestUrl.pathname !== '/mcp') {
343
+ sendError(res, 404, 'Not found');
344
+ return;
345
+ }
346
+ const toolset = resolveToolset(requestUrl.searchParams.get('toolset')
347
+ ?? req.headers['x-orboto-toolset'], process.env.ORBOTO_MCP_TOOLSET);
348
+ // Reject non-POST + non-DELETE. GET on /mcp is legal per the spec
349
+ // (SSE resumption) but that is unimplemented here.
350
+ //
351
+ // ORB-1424 - an UNAUTHENTICATED GET/HEAD on /mcp is almost always an
352
+ // OAuth-discovery probe (rmcp/Codex hit the resource to read the
353
+ // WWW-Authenticate challenge). Answering a bare 405 there left the
354
+ // client without the resource_metadata pointer and logged a noisy
355
+ // "405 Method Not Allowed". Return 401 + WWW-Authenticate instead so
356
+ // the probe discovers the OAuth flow cleanly. An AUTHENTICATED GET
357
+ // (a real client attempting SSE resumption) still 405s - it already
358
+ // holds a token and needs no discovery pointer, so spec-conform
359
+ // clients (Claude Desktop / Cursor) do not regress.
360
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
361
+ const probeAuth = (req.headers.authorization ?? '');
362
+ const hasBearer = probeAuth.startsWith('Bearer ') && probeAuth.slice(7).trim().length > 0;
363
+ if ((req.method === 'GET' || req.method === 'HEAD') && !hasBearer) {
364
+ const challenge = wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required');
365
+ if (req.method === 'HEAD') {
366
+ // HEAD carries no body per HTTP semantics.
367
+ res.writeHead(401, { 'content-type': 'application/json', 'WWW-Authenticate': challenge });
368
+ res.end();
369
+ }
370
+ else {
371
+ sendError(res, 401, 'Bearer token required', { 'WWW-Authenticate': challenge });
372
+ }
373
+ return;
374
+ }
375
+ res.writeHead(405, { allow: 'POST, DELETE' });
376
+ res.end();
377
+ return;
378
+ }
379
+ // Extract the bearer token. Per-session auth - the same token
380
+ // is used for the session's whole lifetime (reconnect without
381
+ // re-init would land on a new session id and fresh auth anyway).
382
+ //
383
+ // Two acceptable token shapes:
384
+ // orb_* - service-account API key (operator-minted)
385
+ // JWT (3 segs) - OAuth-issued access token from the /oauth/
386
+ // authorize + /oauth/token flow (ORB-957)
387
+ // Both go to the API unchanged; the API's authenticate decorator
388
+ // distinguishes them.
389
+ const authHeader = (req.headers.authorization ?? '');
390
+ const token = authHeader.startsWith('Bearer ')
391
+ ? authHeader.slice(7).trim()
392
+ : '';
393
+ if (!token) {
394
+ sendError(res, 401, 'Bearer token required', {
395
+ 'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required'),
396
+ });
397
+ return;
398
+ }
399
+ const sessionId = (req.headers['mcp-session-id'] ?? '');
400
+ // DELETE is the client's explicit session-close. We forward it
401
+ // to the transport's handleRequest which tears down cleanly.
402
+ if (req.method === 'DELETE') {
403
+ const existing = sessions.get(sessionId);
404
+ if (existing) {
405
+ // ORB-1576 - only the session's owner may tear it down (a leaked id
406
+ // must not let someone kill or probe another user's session).
407
+ const presented = await resolveUserEmail(token);
408
+ if (!presented || presented !== existing.userEmail) {
409
+ return sendError(res, 404, 'Unknown MCP session');
410
+ }
411
+ }
412
+ // ORB-1353 - drop the persisted row too so a closed session doesn't
413
+ // linger until the retention sweep. Best-effort + scoped to the caller.
414
+ void store.remove(token, sessionId).catch(() => { });
415
+ if (existing) {
416
+ await existing.transport.handleRequest(req, res);
417
+ return;
418
+ }
419
+ // Not in memory - e.g. the client is closing a session we only know from
420
+ // persistence after a restart. Acknowledge the close idempotently.
421
+ res.writeHead(200, { 'content-type': 'application/json' });
422
+ res.end(JSON.stringify({ ok: true }));
423
+ return;
424
+ }
425
+ // POST path. Parse body so we can route (new-session vs. existing).
426
+ let body;
427
+ try {
428
+ body = await readJsonBody(req);
429
+ }
430
+ catch {
431
+ return sendError(res, 400, 'Malformed JSON');
432
+ }
433
+ const userAgentSuffix = req.headers['user-agent']
434
+ ?.split('/')[0] || undefined;
435
+ const userAgent = req.headers['user-agent'];
436
+ if (sessionId && sessions.has(sessionId)) {
437
+ // Existing session - hand straight to its transport.
438
+ const session = sessions.get(sessionId);
439
+ // ORB-1470 - adopt the bearer the client presents on THIS request. The
440
+ // client's OAuth layer rotates its short-lived (1h) access token; without
441
+ // this the session stays pinned to the token captured at creation and
442
+ // dead-ends with a 401 "OAuth access token expired" once that token ages
443
+ // out, even though the request carried a valid refreshed bearer. Updating
444
+ // the shared holder rotates the bearer for the tool-handler client, the
445
+ // kill-switch client, and the SSE bridge in one assignment.
446
+ if (session.tokenHolder.current !== token) {
447
+ // The client rotated its access token mid-session (the exact case that
448
+ // used to brick the session). ORB-1576 - adopt the new bearer ONLY
449
+ // after it resolves to the session owner's identity: a session id
450
+ // plus ANY valid token must not hijack the session's subscription set
451
+ // / token holder (the id is a 122-bit UUID, but a leaked id is no
452
+ // longer a capability on its own).
453
+ const presented = await resolveUserEmail(token);
454
+ if (!presented || presented !== session.userEmail) {
455
+ return sendError(res, 401, 'Bearer does not match the session owner');
456
+ }
457
+ // Breadcrumb to stderr - Sentry lives on the api side - so operators
458
+ // can see rotations are being absorbed, not re-hitting the pinned-
459
+ // token 401. Low-noise: only on an actual change.
460
+ process.stderr.write(`[orboto-mcp] session ${sessionId} bearer rotated - adopting client's current access token\n`);
461
+ session.tokenHolder.current = token;
462
+ }
463
+ await session.transport.handleRequest(req, res, body);
464
+ // ORB-1353 - slide the persistence TTL on activity so an actively-used
465
+ // session never idle-expires. Throttled + fire-and-forget so it adds no
466
+ // latency and no per-call write storm.
467
+ const now = Date.now();
468
+ if (now - session.lastTouchAt >= TOUCH_THROTTLE_MS) {
469
+ session.lastTouchAt = now;
470
+ void store.register(session.tokenHolder.current, { sessionId }).catch(() => { });
471
+ }
472
+ return;
473
+ }
474
+ // ORB-1175 / ORB-1324 / ORB-1353 - the client presented a session id we
475
+ // don't hold in memory. The common cause is a deploy: the MCP container
476
+ // restarted and lost its in-memory `sessions` map, so every connected
477
+ // client's session id is now unknown. Three outcomes on a non-init request,
478
+ // decided by whether the token is valid and whether the id is a persisted
479
+ // session owned by that token:
480
+ //
481
+ // - no valid auth (or MCP disabled / mcp:use missing) → keep the ORB-1324
482
+ // 404 so spec-conform clients transparently re-initialise (and hit the
483
+ // clear preflight error on the re-init path). NO WWW-Authenticate: the
484
+ // token, not the session, is the thing to fix only when it's actually
485
+ // invalid - which the re-init preflight then challenges.
486
+ // - valid auth + persisted session owned by caller → REHYDRATE under the
487
+ // SAME id (layer 1): the deploy is invisible to a well-behaved client.
488
+ // - valid auth + unknown id → AUTO-ADOPT under a fresh id (layer 2): a
489
+ // buggy client that keeps replaying a dead id self-heals instead of
490
+ // dead-looping on the 404. MCP tool calls are stateless per-call, so
491
+ // silently re-establishing the session loses nothing.
492
+ //
493
+ // The preflight below enforces the kill-switch (ORB-941) + per-user mcp:use
494
+ // on EVERY rehydrate/adopt, exactly like a fresh session - a disabled
495
+ // workspace refuses adopted sessions (they fall to the 404).
496
+ if (sessionId && !isInitializeRequest(body)) {
497
+ const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
498
+ let authValid = false;
499
+ let callerEmail = '';
500
+ try {
501
+ const preflight = await preflightMcpSession(preflightClient);
502
+ authValid = true;
503
+ callerEmail = preflight.userEmail;
504
+ }
505
+ catch {
506
+ // Invalid token, MCP disabled, or mcp:use missing → refuse to
507
+ // re-establish; fall back to the re-initialise 404.
508
+ authValid = false;
509
+ }
510
+ let isPersisted = false;
511
+ if (authValid) {
512
+ try {
513
+ isPersisted = await store.resolve(token, sessionId);
514
+ }
515
+ catch {
516
+ // Store hiccup - treat as not-persisted and let adoption handle it
517
+ // (auth is already valid, so re-establishing is safe).
518
+ isPersisted = false;
519
+ }
520
+ }
521
+ const action = classifyUnknownSession({ hasValidAuth: authValid, isPersistedForCaller: isPersisted });
522
+ if (action === 'reinit-404') {
523
+ return sendError(res, 404, 'Unknown or expired MCP session - reinitialize (the server restarted since this session began).');
524
+ }
525
+ if (action === 'rehydrate') {
526
+ const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset);
527
+ // Touch persistence so the TTL slides; same id, so no adoptedFrom.
528
+ void store.register(token, { sessionId, userAgent }).catch(() => { });
529
+ await session.transport.handleRequest(req, res, body);
530
+ return;
531
+ }
532
+ // action === 'adopt' - mint a fresh id and re-establish under it.
533
+ const newSessionId = randomUUID();
534
+ const session = await establishForcedSession({ current: token }, userAgentSuffix, newSessionId, callerEmail, toolset);
535
+ // Rewrite the in-flight request's session header so the transport
536
+ // validates it against the fresh id AND the response advertises the new
537
+ // id. A well-behaved client migrates to it; a client that keeps sending
538
+ // the dead id simply gets re-adopted each call - self-healing either way.
539
+ overrideSessionIdHeader(req, newSessionId);
540
+ // Record the adoption (old id, new id, client). Stderr here; the api
541
+ // upsert emits the Sentry breadcrumb (Sentry lives on the api side).
542
+ process.stderr.write(`[orboto-mcp] auto-adopted stale session ${sessionId} → ${newSessionId} (client=${userAgent ?? 'unknown'})\n`);
543
+ void store
544
+ .register(token, { sessionId: newSessionId, userAgent, adoptedFrom: sessionId })
545
+ .catch(() => { });
546
+ await session.transport.handleRequest(req, res, body);
547
+ return;
548
+ }
549
+ if (!sessionId && isInitializeRequest(body)) {
550
+ // New session - mint a transport + server pair bound to this
551
+ // request's token, register with the session map once
552
+ // `onsessioninitialized` fires.
553
+ // Preflight: verify mcp:use + mcp_enabled BEFORE we stand up the server,
554
+ // so a refused session costs nothing. If either fails we refuse at the
555
+ // transport level - much clearer than letting the first tool call 403.
556
+ // WWW-Authenticate included on 401-shape failures so the client can
557
+ // auto-discover OAuth.
558
+ const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
559
+ let ownerEmail;
560
+ try {
561
+ ownerEmail = (await preflightMcpSession(preflightClient)).userEmail;
562
+ }
563
+ catch (err) {
564
+ return sendError(res, 401, err.message, {
565
+ 'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_token', err.message),
566
+ });
567
+ }
568
+ // ORB-1470 - the session's mutable bearer holder starts at this request's
569
+ // token and is updated to the client's current bearer on every later call.
570
+ const tokenHolder = { current: token };
571
+ const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
572
+ // "name@version" of the client's declared clientInfo, for observability
573
+ // of which adapter owns the session.
574
+ const clientInfo = clientInfoLabel(body);
575
+ const transport = new StreamableHTTPServerTransport({
576
+ sessionIdGenerator: () => randomUUID(),
577
+ onsessioninitialized: (sid) => {
578
+ sessions.set(sid, {
579
+ transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
580
+ });
581
+ bridge.start();
582
+ // ORB-1353 - persist the freshly-minted session so it survives an
583
+ // api restart (a later request with this id then rehydrates).
584
+ void store
585
+ .register(token, { sessionId: sid, clientInfo, userAgent })
586
+ .catch(() => { });
587
+ },
588
+ });
589
+ transport.onclose = () => {
590
+ if (transport.sessionId)
591
+ sessions.delete(transport.sessionId);
592
+ bridge.close();
593
+ };
594
+ await mcp.connect(transport);
595
+ await transport.handleRequest(req, res, body);
596
+ return;
597
+ }
598
+ // Neither an init nor a known session - the client is confused.
599
+ sendError(res, 400, 'Missing mcp-session-id header or initialize request');
600
+ });
601
+ // Stop the kill-switch poll when the HTTP server is torn down.
602
+ server.on('close', () => clearInterval(killSwitchTimer));
603
+ // ORB-1353 - expose the in-memory registry + store for tests to drive the
604
+ // restart-simulation (clear `sessions` to mimic a process restart while the
605
+ // injected store keeps its persisted rows) and to close lingering sessions.
606
+ server.__mcp = { sessions, store };
607
+ return server;
608
+ }