@orboto/mcp 0.193.0 → 0.194.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 (131) hide show
  1. package/dist/event-bridge.js +1 -47
  2. package/dist/event-bridge.test.js +0 -19
  3. package/dist/http-transport-binding.test.js +1 -4
  4. package/dist/http-transport.js +1 -213
  5. package/dist/http-transport.test.js +0 -56
  6. package/dist/index.js +1 -48
  7. package/dist/input-schema.js +2 -76
  8. package/dist/input-schema.test.js +1 -15
  9. package/dist/instructions-budget.test.js +0 -2
  10. package/dist/manifest-size.test.js +3 -72
  11. package/dist/oauth-bootstrap.js +0 -47
  12. package/dist/oauth-bootstrap.test.js +4 -16
  13. package/dist/orboto-client.js +0 -30
  14. package/dist/orboto-client.test.js +0 -12
  15. package/dist/prompts.js +0 -39
  16. package/dist/protect-text-usage.test.js +1 -9
  17. package/dist/required-rules-wrapper.test.js +0 -5
  18. package/dist/required-rules.js +0 -2
  19. package/dist/resources.js +0 -62
  20. package/dist/resources.test.js +0 -16
  21. package/dist/response-budget.js +2 -150
  22. package/dist/response-budget.test.js +1 -32
  23. package/dist/server.js +4 -180
  24. package/dist/session-nudge.js +0 -9
  25. package/dist/session-nudge.test.js +2 -10
  26. package/dist/session-nudge.transport.test.js +0 -23
  27. package/dist/tool-annotations.test.js +0 -44
  28. package/dist/tool-docs.js +1 -12
  29. package/dist/tool-docs.test.js +2 -8
  30. package/dist/tool-parameters.test.js +0 -19
  31. package/dist/tools/absence-writes.js +1 -17
  32. package/dist/tools/admin-translations.js +0 -11
  33. package/dist/tools/admin-writes.js +0 -28
  34. package/dist/tools/admin-writes.test.js +0 -1
  35. package/dist/tools/agent-coordination.js +2 -24
  36. package/dist/tools/agent-coordination.test.js +0 -2
  37. package/dist/tools/agent-instructions.js +0 -10
  38. package/dist/tools/agent-instructions.test.js +0 -2
  39. package/dist/tools/agent-messages.js +0 -1
  40. package/dist/tools/ai-status.js +0 -14
  41. package/dist/tools/ai-usage.js +0 -10
  42. package/dist/tools/analytics.js +0 -2
  43. package/dist/tools/api-call.js +0 -12
  44. package/dist/tools/api-call.test.js +1 -2
  45. package/dist/tools/api-search.js +1 -6
  46. package/dist/tools/approvals.js +0 -6
  47. package/dist/tools/attach.js +0 -25
  48. package/dist/tools/backup.js +0 -13
  49. package/dist/tools/backup.test.js +1 -2
  50. package/dist/tools/bulk-create.js +1 -25
  51. package/dist/tools/bulk-create.test.js +0 -4
  52. package/dist/tools/bulk-writes.js +69 -61
  53. package/dist/tools/bulk-writes.test.js +34 -4
  54. package/dist/tools/check-similar.js +0 -17
  55. package/dist/tools/checklist-writes.js +82 -27
  56. package/dist/tools/checklist-writes.test.js +69 -1
  57. package/dist/tools/claim.js +1 -71
  58. package/dist/tools/claim.test.js +0 -2
  59. package/dist/tools/critical-path.js +0 -5
  60. package/dist/tools/critical-path.test.js +0 -3
  61. package/dist/tools/cross-project-links.ee.js +1 -24
  62. package/dist/tools/doc-attachments.js +0 -28
  63. package/dist/tools/doc-attachments.test.js +1 -4
  64. package/dist/tools/doc-comments.js +1 -37
  65. package/dist/tools/doc-comments.test.js +1 -2
  66. package/dist/tools/doc-edits.js +1 -31
  67. package/dist/tools/doc-edits.test.js +0 -9
  68. package/dist/tools/doc-export.js +0 -24
  69. package/dist/tools/doc-export.test.js +0 -12
  70. package/dist/tools/doc-revisions.js +0 -23
  71. package/dist/tools/docs-ai.js +0 -34
  72. package/dist/tools/docs-ai.test.js +0 -11
  73. package/dist/tools/docs.js +1 -75
  74. package/dist/tools/docs.test.js +1 -3
  75. package/dist/tools/embedding-status.js +0 -13
  76. package/dist/tools/embedding-status.test.js +0 -1
  77. package/dist/tools/feedback.js +1 -15
  78. package/dist/tools/feedback.test.js +0 -1
  79. package/dist/tools/get-checklists.js +6 -14
  80. package/dist/tools/get-project-primer.js +1 -25
  81. package/dist/tools/get-project.js +0 -17
  82. package/dist/tools/get-ticket.js +1 -61
  83. package/dist/tools/get-timer.js +0 -11
  84. package/dist/tools/git-health-reasons.js +0 -1
  85. package/dist/tools/identity.js +0 -13
  86. package/dist/tools/list-projects.js +0 -20
  87. package/dist/tools/list-projects.test.js +0 -1
  88. package/dist/tools/list-tickets.js +10 -9
  89. package/dist/tools/milestones-crud.test.js +0 -4
  90. package/dist/tools/milestones.js +0 -43
  91. package/dist/tools/my-tickets.js +0 -7
  92. package/dist/tools/phase-b-tools.test.js +14 -21
  93. package/dist/tools/primer-facts.js +1 -49
  94. package/dist/tools/primer-facts.test.js +0 -20
  95. package/dist/tools/project-listings.js +0 -25
  96. package/dist/tools/query.js +1 -12
  97. package/dist/tools/query.test.js +0 -8
  98. package/dist/tools/raci.js +0 -11
  99. package/dist/tools/requirements-spec.js +1 -12
  100. package/dist/tools/response-expand.js +0 -19
  101. package/dist/tools/review-policy.js +0 -24
  102. package/dist/tools/review-policy.test.js +0 -2
  103. package/dist/tools/search.js +1 -13
  104. package/dist/tools/session-start.js +1 -136
  105. package/dist/tools/session-start.test.js +1 -44
  106. package/dist/tools/set-parent.js +1 -19
  107. package/dist/tools/shared.js +0 -41
  108. package/dist/tools/shared.test.js +0 -4
  109. package/dist/tools/similar-projection.js +0 -15
  110. package/dist/tools/ticket-attachments.js +1 -25
  111. package/dist/tools/ticket-schedules.js +0 -9
  112. package/dist/tools/ticket-schedules.test.js +0 -1
  113. package/dist/tools/ticket-writes.js +1 -110
  114. package/dist/tools/ticket-writes.test.js +1 -15
  115. package/dist/tools/time-writes.js +26 -46
  116. package/dist/tools/time-writes.test.js +18 -0
  117. package/dist/tools/update-project.js +1 -27
  118. package/dist/tools/update-project.test.js +1 -1
  119. package/dist/tools/wiki.js +0 -8
  120. package/dist/tools/work-next.js +0 -5
  121. package/dist/tools/work-sessions-start.test.js +0 -2
  122. package/dist/tools/work-sessions.js +0 -48
  123. package/dist/tools/work-sessions.test.js +0 -5
  124. package/dist/toolset.js +2 -81
  125. package/dist/toolset.test.js +0 -20
  126. package/dist/version-guard.test.js +0 -4
  127. package/dist/version.js +0 -12
  128. package/dist/with-metrics-output-schema.test.js +1 -14
  129. package/dist/with-metrics.js +2 -74
  130. package/dist/with-metrics.test.js +7 -29
  131. package/package.json +1 -1
@@ -1,13 +1,10 @@
1
- const HEARTBEAT_GRACE_MS = 90_000; // 3× the server's 30s ping
1
+ const HEARTBEAT_GRACE_MS = 90_000;
2
2
  const OVERFLOW_THRESHOLD = 100;
3
3
  const RECONNECT_DELAY_MS = 2_000;
4
4
  /** Map a raw event to the set of `orboto://` URIs that should be
5
5
  * notified. Returns an empty array for events that don't map to any
6
6
  * subscribed resource shape (no-op). */
7
7
  export function eventToUris(event) {
8
- // Ticket-level events. Include the ticket URI (when the payload
9
- // carries a ticketKey) AND the project URI so a project-scoped
10
- // subscriber learns about activity inside it.
11
8
  if (event.type === 'ticket:created' || event.type === 'ticket:updated' || event.type === 'ticket:deleted'
12
9
  || event.type === 'ticket:activity'
13
10
  || event.type === 'comment:created' || event.type === 'comment:updated' || event.type === 'comment:deleted'
@@ -17,16 +14,8 @@ export function eventToUris(event) {
17
14
  const key = event.payload?.ticketKey;
18
15
  if (key)
19
16
  uris.push(`orboto://ticket/${key}`);
20
- // The MCP bridge doesn't know each project's key; the project URI
21
- // form uses the projectKey which isn't on the wire. We fall back
22
- // to projectId, which the resource handler accepts via
23
- // resolveProjectByKey's lookup path.
24
17
  if (event.projectId)
25
18
  uris.push(`orboto://project/${event.projectId}`);
26
- // ORB-962 - hand-off "wake on close". When a ticket:updated
27
- // payload's statusCategory is `done`, emit an additional URI
28
- // dedicated to close-events so subscribers don't have to filter
29
- // every ticket:updated client-side.
30
19
  if (event.type === 'ticket:updated' && key && event.payload?.statusCategory === 'done') {
31
20
  uris.push(`orboto://handoff/closed/${key}`);
32
21
  }
@@ -35,19 +24,9 @@ export function eventToUris(event) {
35
24
  if (event.type === 'project:bulk-activity' || event.type === 'member:joined' || event.type === 'member:removed') {
36
25
  return event.projectId ? [`orboto://project/${event.projectId}`] : [];
37
26
  }
38
- // ORB-1616 - dispatcher wake-on-unblock. Project-scoped (not per-ticket
39
- // like `handoff/closed`) because a worker pool doesn't know in advance
40
- // which ticket will become ready next - it subscribes to the PROJECT's
41
- // ready channel and pulls via `orboto_work_next` on push. The
42
- // notification carries no payload (the resource-subscription protocol
43
- // never does) - `ticket.ready` is the webhook channel that carries the
44
- // actual ticket key for external consumers.
45
27
  if (event.type === 'ticket:ready') {
46
28
  return event.projectId ? [`orboto://ready/${event.projectId}`] : [];
47
29
  }
48
- // ORB-1616 - escalation push: collision / failed checks / review
49
- // rejection / missing decision / git failure. Project-scoped, same
50
- // reasoning as `ready` above.
51
30
  if (event.type === 'agent_escalation:raised') {
52
31
  return event.projectId ? [`orboto://escalation/${event.projectId}`] : [];
53
32
  }
@@ -57,9 +36,6 @@ export function eventToUris(event) {
57
36
  if (event.type === 'timer:changed') {
58
37
  return [`orboto://timer`];
59
38
  }
60
- // ORB-964 - agent broadcast. Map to the scope-specific URI so
61
- // subscribers can register interest in just the scope they care
62
- // about. For workspace scope, scopeId is the empty string.
63
39
  if (event.type === 'agent_broadcast:posted') {
64
40
  const p = event.payload;
65
41
  if (p?.scopeType) {
@@ -68,22 +44,13 @@ export function eventToUris(event) {
68
44
  }
69
45
  return [];
70
46
  }
71
- // ORB-970 - quorum lifecycle.
72
47
  if (event.type === 'agent_quorum:opened' || event.type === 'agent_quorum:approved') {
73
48
  const p = event.payload;
74
49
  return p?.topicKey ? [`orboto://quorum/${p.topicKey}`] : [];
75
50
  }
76
- // ORB-706 - mention real-time push. Every notification row firing
77
- // for the calling user surfaces on `orboto://user/me/notifications`.
78
- // The API-side SSE bridge already filters notification:new events
79
- // to only deliver them to the matching user's session, so this
80
- // URI is naturally per-user-scoped.
81
51
  if (event.type === 'notification:new') {
82
52
  return [`orboto://user/me/notifications`];
83
53
  }
84
- // restore:progress / system-task:* are user-scoped infrastructure
85
- // events that don't map to a public resource URI today. The MCP
86
- // bridge ignores them - the user gets them via the in-app UI.
87
54
  return [];
88
55
  }
89
56
  export class EventBridge {
@@ -137,13 +104,8 @@ export class EventBridge {
137
104
  }
138
105
  async consume() {
139
106
  const fetchFn = this.opts.fetchFn ?? fetch;
140
- // Fresh abort controller per attempt so close() during a retry
141
- // window doesn't leak the previous signal.
142
107
  this.abort = new AbortController();
143
108
  const url = `${this.opts.baseUrl.replace(/\/$/, '')}/sse/mcp-events`;
144
- // ORB-1470 - resolve the current bearer per connect so a rotated OAuth
145
- // access token is picked up on the next reconnect instead of pinning the
146
- // stream to the session's creation-time token.
147
109
  const bearer = this.opts.tokenProvider
148
110
  ? await this.opts.tokenProvider.getAccessToken()
149
111
  : (this.opts.apiKey ?? '');
@@ -164,9 +126,6 @@ export class EventBridge {
164
126
  let buf = '';
165
127
  let lastFrame = Date.now();
166
128
  const heartbeat = setInterval(() => {
167
- // If the server's ping stops arriving the underlying TCP
168
- // connection might be dead silent (Coolify / NAT timeout). Force
169
- // a reconnect so we don't sit forever on a zombie stream.
170
129
  if (Date.now() - lastFrame > HEARTBEAT_GRACE_MS) {
171
130
  try {
172
131
  this.abort.abort();
@@ -175,8 +134,6 @@ export class EventBridge {
175
134
  }
176
135
  }, 30_000);
177
136
  try {
178
- // Read until the stream ends, the request was aborted, or the
179
- // process is shutting down.
180
137
  // eslint-disable-next-line no-constant-condition
181
138
  while (true) {
182
139
  const { done, value } = await reader.read();
@@ -184,9 +141,6 @@ export class EventBridge {
184
141
  break;
185
142
  lastFrame = Date.now();
186
143
  buf += decoder.decode(value, { stream: true });
187
- // SSE frames are split by '\n\n'. We handle both 'data: ...'
188
- // and ': ping' (comment / heartbeat) lines; the latter become
189
- // a no-op event.
190
144
  let idx;
191
145
  // eslint-disable-next-line no-cond-assign
192
146
  while ((idx = buf.indexOf('\n\n')) !== -1) {
@@ -1,16 +1,5 @@
1
1
  /**
2
2
  * ORB-940 - EventBridge unit tests.
3
- *
4
- * Three concerns:
5
- * 1. Event → URI mapping (pure function, no I/O)
6
- * 2. Dispatch behaviour - subscribed URIs trigger
7
- * sendResourceUpdated, unsubscribed do not
8
- * 3. Backpressure - > 100 forwarded events → one
9
- * sendResourceListChanged instead of more updates
10
- *
11
- * The actual SSE consumption path is exercised via a streaming
12
- * mock fetch; the response body is a ReadableStream that yields
13
- * one frame per chunk, then closes.
14
3
  */
15
4
  import { describe, expect, it, vi } from 'vitest';
16
5
  import { EventBridge, eventToUris } from './event-bridge.js';
@@ -64,9 +53,6 @@ describe('eventToUris', () => {
64
53
  expect(eventToUris({ type: 'timer:changed', userId: 'u1' })).toEqual(['orboto://timer']);
65
54
  });
66
55
  it('maps notification:new to the calling user\'s notifications resource (ORB-706)', () => {
67
- // The mcp-events SSE handler already filters notification:new
68
- // to only deliver to the matching user; the URI is per-user
69
- // scoped by construction.
70
56
  expect(eventToUris({ type: 'notification:new', userId: 'u1' })).toEqual(['orboto://user/me/notifications']);
71
57
  });
72
58
  it('returns [] for restore-progress / system-task events (infrastructure-only)', () => {
@@ -107,7 +93,6 @@ describe('EventBridge.dispatch', () => {
107
93
  log: () => { },
108
94
  });
109
95
  bridge.start();
110
- // Give the microtask loop a couple of ticks to drain.
111
96
  await new Promise((r) => setTimeout(r, 50));
112
97
  bridge.close();
113
98
  expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(1);
@@ -116,8 +101,6 @@ describe('EventBridge.dispatch', () => {
116
101
  it('falls back to sendResourceListChanged after the overflow threshold', async () => {
117
102
  const mcp = mockMcp();
118
103
  const subscriptions = new Set(['orboto://timer']);
119
- // 101 timer events - one more than the threshold so the 101st
120
- // round trips through the list_changed path.
121
104
  const frames = Array.from({ length: 101 }, () => ({ type: 'timer:changed', userId: 'u1' }));
122
105
  const fetchFn = vi.fn().mockResolvedValueOnce(mockSseResponse(frames));
123
106
  const bridge = new EventBridge({
@@ -137,8 +120,6 @@ describe('EventBridge.dispatch', () => {
137
120
  it('survives malformed frames without aborting the stream', async () => {
138
121
  const mcp = mockMcp();
139
122
  const subscriptions = new Set(['orboto://ticket/ORB-42']);
140
- // Inject one bad frame between two good ones - the bad frame is
141
- // a non-JSON 'data:' line, which the bridge should skip.
142
123
  const encoder = new TextEncoder();
143
124
  const stream = new ReadableStream({
144
125
  start(controller) {
@@ -102,11 +102,9 @@ describe('ORB-1576 - session binding', () => {
102
102
  it('rotation adopted for the SAME user, rejected for a different user', async () => {
103
103
  const base = await start();
104
104
  const sid = await initSession(base, 'orb_alice');
105
- // Same user, rotated token -> adopted (200).
106
105
  const rotated = await post(base, NON_INIT, { authorization: 'Bearer orb_alice_rot', 'mcp-session-id': sid });
107
106
  expect(rotated.status).toBe(200);
108
107
  await rotated.text();
109
- // Different user with a VALID token -> 401, holder not adopted.
110
108
  const hijack = await post(base, NON_INIT, { authorization: 'Bearer orb_mallory', 'mcp-session-id': sid });
111
109
  expect(hijack.status).toBe(401);
112
110
  await hijack.text();
@@ -142,10 +140,9 @@ describe('ORB-1576 - request body cap', () => {
142
140
  await res.text();
143
141
  }
144
142
  catch {
145
- errored = true; // connection reset mid-write is an acceptable outcome too
143
+ errored = true;
146
144
  }
147
145
  expect(errored || status === 400).toBe(true);
148
146
  });
149
147
  });
150
- // vitest fake-timer hygiene for the bridge internals
151
148
  void vi;
@@ -1,33 +1,7 @@
1
1
  /**
2
2
  * ORB-244 Phase A - Streamable HTTP transport for the MCP server.
3
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.
4
+ * @see ORB-1353, ORB-1324
31
5
  */
32
6
  import { createServer } from 'node:http';
33
7
  import { randomUUID } from 'node:crypto';
@@ -112,19 +86,6 @@ export function clientInfoLabel(body) {
112
86
  /**
113
87
  * ORB-941 - graceful close of every active MCP session when the
114
88
  * 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
89
  */
129
90
  export async function closeAllMcpSessions(sessions, reason) {
130
91
  let closed = 0;
@@ -190,25 +151,10 @@ function sendError(res, status, message, extraHeaders) {
190
151
  /** ORB-957 - RFC 6750 §3 WWW-Authenticate challenge for /mcp 401s.
191
152
  * MCP-aware clients (Claude Desktop, Cursor, VS Code Copilot) follow
192
153
  * 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
154
  */
202
155
  function wwwAuthChallenge(req, baseUrl, error, description) {
203
156
  const host = req.headers['x-forwarded-host']
204
157
  || 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
158
  const hostIsLocal = !!host && /^(localhost|127\.|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host);
213
159
  const protoHeader = req.headers['x-forwarded-proto'];
214
160
  const proto = protoHeader || (hostIsLocal ? 'http' : 'https');
@@ -219,45 +165,16 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
219
165
  return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
220
166
  }
221
167
  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
168
  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
169
  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
170
  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
171
  const tokenProvider = holderTokenProvider(tokenHolder);
238
172
  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
173
  const subscriptions = new Set();
254
174
  const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
255
175
  const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
256
176
  return { sessionClient, subscriptions, mcp, bridge };
257
177
  }
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
178
  /** ORB-1576 - resolve a bearer's owner identity via the status preflight;
262
179
  * null on any failure (invalid token, MCP off, network) = fail closed. */
263
180
  async function resolveUserEmail(token) {
@@ -283,18 +200,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
283
200
  bridge.start();
284
201
  return session;
285
202
  }
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
203
  const pollMs = Number(process.env.ORBOTO_MCP_KILLSWITCH_POLL_MS ?? 30_000);
299
204
  async function pollKillSwitch() {
300
205
  if (sessions.size === 0)
@@ -304,8 +209,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
304
209
  try {
305
210
  const status = await session.client.get('/system/mcp/status');
306
211
  if (!status.enabled) {
307
- // Workspace-wide off wins and is authoritative for everyone -
308
- // close all sessions and stop probing.
309
212
  await closeAllMcpSessions(sessions.values(), 'the workspace administrator has disabled MCP access.');
310
213
  return;
311
214
  }
@@ -321,23 +224,13 @@ export function createHttpServer({ baseUrl, sessionStore }) {
321
224
  }
322
225
  }
323
226
  const killSwitchTimer = setInterval(() => { void pollKillSwitch(); }, pollMs);
324
- // Don't let the poll keep the process (or a test) alive on its own.
325
227
  killSwitchTimer.unref?.();
326
228
  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
229
  if (req.method === 'GET' && req.url === '/health') {
330
230
  res.writeHead(200, { 'content-type': 'application/json' });
331
231
  res.end(JSON.stringify({ status: 'ok' }));
332
232
  return;
333
233
  }
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
234
  const requestUrl = new URL(req.url ?? '', 'http://localhost');
342
235
  if (requestUrl.pathname !== '/mcp') {
343
236
  sendError(res, 404, 'Not found');
@@ -345,25 +238,12 @@ export function createHttpServer({ baseUrl, sessionStore }) {
345
238
  }
346
239
  const toolset = resolveToolset(requestUrl.searchParams.get('toolset')
347
240
  ?? 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
241
  if (req.method !== 'POST' && req.method !== 'DELETE') {
361
242
  const probeAuth = (req.headers.authorization ?? '');
362
243
  const hasBearer = probeAuth.startsWith('Bearer ') && probeAuth.slice(7).trim().length > 0;
363
244
  if ((req.method === 'GET' || req.method === 'HEAD') && !hasBearer) {
364
245
  const challenge = wwwAuthChallenge(req, baseUrl, 'invalid_request', 'Bearer token required');
365
246
  if (req.method === 'HEAD') {
366
- // HEAD carries no body per HTTP semantics.
367
247
  res.writeHead(401, { 'content-type': 'application/json', 'WWW-Authenticate': challenge });
368
248
  res.end();
369
249
  }
@@ -376,16 +256,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
376
256
  res.end();
377
257
  return;
378
258
  }
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
259
  const authHeader = (req.headers.authorization ?? '');
390
260
  const token = authHeader.startsWith('Bearer ')
391
261
  ? authHeader.slice(7).trim()
@@ -397,32 +267,23 @@ export function createHttpServer({ baseUrl, sessionStore }) {
397
267
  return;
398
268
  }
399
269
  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
270
  if (req.method === 'DELETE') {
403
271
  const existing = sessions.get(sessionId);
404
272
  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
273
  const presented = await resolveUserEmail(token);
408
274
  if (!presented || presented !== existing.userEmail) {
409
275
  return sendError(res, 404, 'Unknown MCP session');
410
276
  }
411
277
  }
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
278
  void store.remove(token, sessionId).catch(() => { });
415
279
  if (existing) {
416
280
  await existing.transport.handleRequest(req, res);
417
281
  return;
418
282
  }
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
283
  res.writeHead(200, { 'content-type': 'application/json' });
422
284
  res.end(JSON.stringify({ ok: true }));
423
285
  return;
424
286
  }
425
- // POST path. Parse body so we can route (new-session vs. existing).
426
287
  let body;
427
288
  try {
428
289
  body = await readJsonBody(req);
@@ -434,36 +295,16 @@ export function createHttpServer({ baseUrl, sessionStore }) {
434
295
  ?.split('/')[0] || undefined;
435
296
  const userAgent = req.headers['user-agent'];
436
297
  if (sessionId && sessions.has(sessionId)) {
437
- // Existing session - hand straight to its transport.
438
298
  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
299
  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
300
  const presented = await resolveUserEmail(token);
454
301
  if (!presented || presented !== session.userEmail) {
455
302
  return sendError(res, 401, 'Bearer does not match the session owner');
456
303
  }
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
304
  process.stderr.write(`[orboto-mcp] session ${sessionId} bearer rotated - adopting client's current access token\n`);
461
305
  session.tokenHolder.current = token;
462
306
  }
463
307
  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
308
  const now = Date.now();
468
309
  if (now - session.lastTouchAt >= TOUCH_THROTTLE_MS) {
469
310
  session.lastTouchAt = now;
@@ -471,28 +312,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
471
312
  }
472
313
  return;
473
314
  }
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
315
  if (sessionId && !isInitializeRequest(body)) {
497
316
  const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
498
317
  let authValid = false;
@@ -503,8 +322,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
503
322
  callerEmail = preflight.userEmail;
504
323
  }
505
324
  catch {
506
- // Invalid token, MCP disabled, or mcp:use missing → refuse to
507
- // re-establish; fall back to the re-initialise 404.
508
325
  authValid = false;
509
326
  }
510
327
  let isPersisted = false;
@@ -513,8 +330,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
513
330
  isPersisted = await store.resolve(token, sessionId);
514
331
  }
515
332
  catch {
516
- // Store hiccup - treat as not-persisted and let adoption handle it
517
- // (auth is already valid, so re-establishing is safe).
518
333
  isPersisted = false;
519
334
  }
520
335
  }
@@ -524,21 +339,13 @@ export function createHttpServer({ baseUrl, sessionStore }) {
524
339
  }
525
340
  if (action === 'rehydrate') {
526
341
  const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset);
527
- // Touch persistence so the TTL slides; same id, so no adoptedFrom.
528
342
  void store.register(token, { sessionId, userAgent }).catch(() => { });
529
343
  await session.transport.handleRequest(req, res, body);
530
344
  return;
531
345
  }
532
- // action === 'adopt' - mint a fresh id and re-establish under it.
533
346
  const newSessionId = randomUUID();
534
347
  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
348
  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
349
  process.stderr.write(`[orboto-mcp] auto-adopted stale session ${sessionId} → ${newSessionId} (client=${userAgent ?? 'unknown'})\n`);
543
350
  void store
544
351
  .register(token, { sessionId: newSessionId, userAgent, adoptedFrom: sessionId })
@@ -547,14 +354,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
547
354
  return;
548
355
  }
549
356
  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
357
  const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
559
358
  let ownerEmail;
560
359
  try {
@@ -565,12 +364,8 @@ export function createHttpServer({ baseUrl, sessionStore }) {
565
364
  'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_token', err.message),
566
365
  });
567
366
  }
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
367
  const tokenHolder = { current: token };
571
368
  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
369
  const clientInfo = clientInfoLabel(body);
575
370
  const transport = new StreamableHTTPServerTransport({
576
371
  sessionIdGenerator: () => randomUUID(),
@@ -579,8 +374,6 @@ export function createHttpServer({ baseUrl, sessionStore }) {
579
374
  transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
580
375
  });
581
376
  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
377
  void store
585
378
  .register(token, { sessionId: sid, clientInfo, userAgent })
586
379
  .catch(() => { });
@@ -595,14 +388,9 @@ export function createHttpServer({ baseUrl, sessionStore }) {
595
388
  await transport.handleRequest(req, res, body);
596
389
  return;
597
390
  }
598
- // Neither an init nor a known session - the client is confused.
599
391
  sendError(res, 400, 'Missing mcp-session-id header or initialize request');
600
392
  });
601
- // Stop the kill-switch poll when the HTTP server is torn down.
602
393
  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
394
  server.__mcp = { sessions, store };
607
395
  return server;
608
396
  }