@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,408 @@
1
+ /**
2
+ * ORB-1175 - after a deploy the MCP container loses its in-memory session
3
+ * map, so a client's existing mcp-session-id is unknown. The transport
4
+ * must answer 404 (the Streamable-HTTP spec signal to re-initialise)
5
+ * rather than the old opaque 400, so OAuth-connected clients recover
6
+ * transparently instead of failing with a generic execution error.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { createServer as createNodeServer } from 'node:http';
10
+ import { createHttpServer, closeAllMcpSessions, classifyUnknownSession, clientInfoLabel, } from './http-transport.js';
11
+ let server = null;
12
+ afterEach(() => { server?.close(); server = null; });
13
+ async function start() {
14
+ server = createHttpServer({ baseUrl: 'http://api.invalid' });
15
+ await new Promise((resolve) => server.listen(0, resolve));
16
+ const { port } = server.address();
17
+ return `http://127.0.0.1:${port}`;
18
+ }
19
+ const NON_INIT = JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 });
20
+ describe('ORB-1175 - stale MCP session recovery', () => {
21
+ it('returns a clean 404 (re-initialise signal, no auth challenge) for an unknown session id on a non-init request', async () => {
22
+ const base = await start();
23
+ const res = await fetch(`${base}/mcp`, {
24
+ method: 'POST',
25
+ headers: { authorization: 'Bearer orb_dummy', 'content-type': 'application/json', 'mcp-session-id': 'stale-after-deploy' },
26
+ body: NON_INIT,
27
+ });
28
+ expect(res.status).toBe(404);
29
+ // ORB-1324 - NO WWW-Authenticate: the token is fine, only the session is
30
+ // gone. Attaching an auth challenge made clients kick off a manual OAuth
31
+ // re-auth instead of the automatic re-initialise the 404 already signals.
32
+ // (Token expiry is still caught on the re-init path's preflight.)
33
+ expect(res.headers.get('www-authenticate')).toBeNull();
34
+ const body = await res.json();
35
+ expect(body.error).toMatch(/reinitialize/i);
36
+ });
37
+ it('still 401s when no bearer token is present', async () => {
38
+ const base = await start();
39
+ const res = await fetch(`${base}/mcp`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json', 'mcp-session-id': 'whatever' },
42
+ body: NON_INIT,
43
+ });
44
+ expect(res.status).toBe(401);
45
+ });
46
+ it('health probe stays open + cheap', async () => {
47
+ const base = await start();
48
+ const res = await fetch(`${base}/health`);
49
+ expect(res.status).toBe(200);
50
+ expect(await res.json()).toEqual({ status: 'ok' });
51
+ });
52
+ });
53
+ describe('ORB-1424 - GET /mcp OAuth-discovery probe', () => {
54
+ it('an unauthenticated GET /mcp returns 401 + WWW-Authenticate (not 405) so discovery finds the resource metadata', async () => {
55
+ const base = await start();
56
+ const res = await fetch(`${base}/mcp`, { method: 'GET' });
57
+ expect(res.status).toBe(401);
58
+ const challenge = res.headers.get('www-authenticate');
59
+ expect(challenge).toBeTruthy();
60
+ expect(challenge).toContain('resource_metadata=');
61
+ expect(challenge).toContain('/.well-known/oauth-protected-resource');
62
+ });
63
+ it('an authenticated GET /mcp still 405s (SSE resumption unimplemented; no discovery needed)', async () => {
64
+ const base = await start();
65
+ const res = await fetch(`${base}/mcp`, {
66
+ method: 'GET',
67
+ headers: { authorization: 'Bearer orb_dummy' },
68
+ });
69
+ expect(res.status).toBe(405);
70
+ expect(res.headers.get('allow')).toBe('POST, DELETE');
71
+ // A held token needs no auth challenge on the 405.
72
+ expect(res.headers.get('www-authenticate')).toBeNull();
73
+ });
74
+ });
75
+ describe('ORB-941 - graceful close of in-flight MCP sessions on kill-switch', () => {
76
+ function fakeSession() {
77
+ const close = vi.fn().mockResolvedValue(undefined);
78
+ const log = vi.fn().mockResolvedValue(undefined);
79
+ const session = {
80
+ transport: { close },
81
+ mcp: { server: { sendLoggingMessage: log } },
82
+ client: {},
83
+ bridge: { close: vi.fn() },
84
+ tokenHolder: { current: 'orb_dummy' },
85
+ userEmail: 'owner@orboto.test',
86
+ lastTouchAt: Date.now(),
87
+ };
88
+ return { session, close, log };
89
+ }
90
+ it('emits a logging notice then closes every active transport', async () => {
91
+ const a = fakeSession();
92
+ const b = fakeSession();
93
+ const closed = await closeAllMcpSessions([a.session, b.session], 'disabled by admin.');
94
+ expect(closed).toBe(2);
95
+ expect(a.log).toHaveBeenCalledOnce();
96
+ expect(b.log).toHaveBeenCalledOnce();
97
+ expect(a.close).toHaveBeenCalledOnce();
98
+ expect(b.close).toHaveBeenCalledOnce();
99
+ // The reason is surfaced to the client in the notification payload.
100
+ expect(a.log.mock.calls[0][0]).toMatchObject({ level: 'warning' });
101
+ expect(a.log.mock.calls[0][0].data).toContain('disabled by admin.');
102
+ });
103
+ it('still closes the transport when the client never negotiated logging', async () => {
104
+ const s = fakeSession();
105
+ s.log.mockRejectedValueOnce(new Error('logging capability not negotiated'));
106
+ const closed = await closeAllMcpSessions([s.session], 'disabled by admin.');
107
+ expect(closed).toBe(1);
108
+ expect(s.close).toHaveBeenCalledOnce();
109
+ });
110
+ });
111
+ // ---------------------------------------------------------------------------
112
+ // ORB-1353 - session resilience: persist across restarts + auto-adopt.
113
+ // ---------------------------------------------------------------------------
114
+ describe('ORB-1353 - classifyUnknownSession (pure decision)', () => {
115
+ it('keeps the 404 re-init contract when auth is invalid', () => {
116
+ expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: false })).toBe('reinit-404');
117
+ // Even a "persisted" id must not resurrect without valid auth.
118
+ expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: true })).toBe('reinit-404');
119
+ });
120
+ it('rehydrates a persisted session owned by the caller (layer 1)', () => {
121
+ expect(classifyUnknownSession({ hasValidAuth: true, isPersistedForCaller: true })).toBe('rehydrate');
122
+ });
123
+ it('adopts an unknown id under valid auth (layer 2)', () => {
124
+ expect(classifyUnknownSession({ hasValidAuth: true, isPersistedForCaller: false })).toBe('adopt');
125
+ });
126
+ });
127
+ describe('ORB-1353 - clientInfoLabel', () => {
128
+ it('formats name@version from an initialize body', () => {
129
+ expect(clientInfoLabel({ params: { clientInfo: { name: 'zcode', version: '2.1' } } })).toBe('zcode@2.1');
130
+ });
131
+ it('falls back to name-only and undefined', () => {
132
+ expect(clientInfoLabel({ params: { clientInfo: { name: 'zcode' } } })).toBe('zcode');
133
+ expect(clientInfoLabel({ params: {} })).toBeUndefined();
134
+ expect(clientInfoLabel(null)).toBeUndefined();
135
+ });
136
+ });
137
+ // A controllable in-process fake of the api endpoints the transport touches:
138
+ // preflight (/system/mcp/status), the working-rules fetch, and the event
139
+ // bridge's SSE endpoint. DB-free - the persisted-session store is injected
140
+ // separately so a "restart" is just clearing the in-memory registry.
141
+ function fakeApi(opts = {}) {
142
+ const state = {
143
+ enabled: opts.enabled ?? true,
144
+ mcpUseGranted: opts.mcpUseGranted ?? true,
145
+ // ORB-942 - per-user MCP opt-out. Defaults on so existing flows are unaffected.
146
+ userMcpEnabled: opts.userMcpEnabled ?? true,
147
+ // ORB-1470 - record every Authorization header the api sees so a test can
148
+ // assert which bearer the transport forwarded upstream.
149
+ seenAuth: [],
150
+ };
151
+ const api = createNodeServer((req, res) => {
152
+ const url = req.url ?? '';
153
+ if (url.startsWith('/system/mcp/status')) {
154
+ state.seenAuth.push((req.headers.authorization ?? ''));
155
+ // A token of `Bearer invalid` models an expired/invalid credential.
156
+ if ((req.headers.authorization ?? '') === 'Bearer invalid') {
157
+ res.writeHead(401, { 'content-type': 'application/json' });
158
+ res.end(JSON.stringify({ error: 'invalid token' }));
159
+ return;
160
+ }
161
+ res.writeHead(200, { 'content-type': 'application/json' });
162
+ res.end(JSON.stringify({
163
+ enabled: state.enabled,
164
+ mcpUseGranted: state.mcpUseGranted,
165
+ userMcpEnabled: state.userMcpEnabled,
166
+ userEmail: 'u@example.com',
167
+ }));
168
+ return;
169
+ }
170
+ if (url.startsWith('/agent-instructions')) {
171
+ res.writeHead(200, { 'content-type': 'application/json' });
172
+ res.end(JSON.stringify({ instructions: '' }));
173
+ return;
174
+ }
175
+ if (url.startsWith('/sse/mcp-events')) {
176
+ // Empty stream - the bridge reconnects on close, cleared by session close.
177
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
178
+ res.end();
179
+ return;
180
+ }
181
+ res.writeHead(404, { 'content-type': 'application/json' });
182
+ res.end('{}');
183
+ });
184
+ return { api, state };
185
+ }
186
+ /** In-memory persisted-session store. Survives an in-memory-registry "restart";
187
+ * keyed identity by token (1:1 with a user in these tests). */
188
+ function fakeStore() {
189
+ const rows = new Map();
190
+ const register = vi.fn(async (token, meta) => {
191
+ rows.set(meta.sessionId, { userId: token, adoptedFrom: meta.adoptedFrom });
192
+ });
193
+ const resolve = vi.fn(async (token, sessionId) => {
194
+ const r = rows.get(sessionId);
195
+ return !!r && r.userId === token;
196
+ });
197
+ const remove = vi.fn(async (_token, sessionId) => { rows.delete(sessionId); });
198
+ const store = { register, resolve, remove };
199
+ return { rows, register, resolve, remove, store };
200
+ }
201
+ const INIT_BODY = JSON.stringify({
202
+ jsonrpc: '2.0',
203
+ id: 1,
204
+ method: 'initialize',
205
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test-client', version: '1.0' } },
206
+ });
207
+ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () => {
208
+ let api = null;
209
+ const cleanups = [];
210
+ afterEach(async () => {
211
+ // Close any live MCP sessions so their event-bridge retry timers don't leak.
212
+ for (const c of cleanups.splice(0))
213
+ c();
214
+ if (server) {
215
+ const internals = server.__mcp;
216
+ if (internals) {
217
+ for (const s of internals.sessions.values()) {
218
+ try {
219
+ await s.transport.close();
220
+ }
221
+ catch { /* ignore */ }
222
+ }
223
+ }
224
+ }
225
+ await new Promise((r) => (api ? api.close(() => r()) : r()));
226
+ api = null;
227
+ });
228
+ async function startWithFakeApi(store, fake = fakeApi()) {
229
+ api = fake.api;
230
+ await new Promise((r) => api.listen(0, r));
231
+ const { port: apiPort } = api.address();
232
+ server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
233
+ await new Promise((r) => server.listen(0, r));
234
+ const { port } = server.address();
235
+ const internals = server.__mcp;
236
+ return { base: `http://127.0.0.1:${port}`, internals };
237
+ }
238
+ async function post(base, body, headers) {
239
+ return fetch(`${base}/mcp`, {
240
+ method: 'POST',
241
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
242
+ body,
243
+ });
244
+ }
245
+ it('rehydrates the SAME session id after an in-memory restart (layer 1)', async () => {
246
+ const store = fakeStore();
247
+ const { base, internals } = await startWithFakeApi(store.store);
248
+ // 1. Initialise a session and capture its id.
249
+ const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_alice' });
250
+ expect(initRes.status).toBe(200);
251
+ const sid = initRes.headers.get('mcp-session-id');
252
+ await initRes.text();
253
+ expect(sid).toBeTruthy();
254
+ // The freshly-minted session was persisted.
255
+ await vi.waitFor(() => expect(store.rows.has(sid)).toBe(true));
256
+ // 2. Simulate an api restart: the in-memory registry is wiped, the store
257
+ // (DB) survives.
258
+ internals.sessions.clear();
259
+ // 3. The next call with the OLD id + valid auth rehydrates under the SAME id.
260
+ const res = await post(base, NON_INIT, { authorization: 'Bearer orb_alice', 'mcp-session-id': sid });
261
+ expect(res.status).toBe(200);
262
+ expect(res.headers.get('mcp-session-id')).toBe(sid);
263
+ await res.text();
264
+ expect(store.resolve).toHaveBeenCalledWith('orb_alice', sid);
265
+ });
266
+ it('auto-adopts an unknown session id under a FRESH id when auth is valid (layer 2)', async () => {
267
+ const store = fakeStore();
268
+ const { base } = await startWithFakeApi(store.store);
269
+ const stale = 'sess_dead_from_zcode';
270
+ const res = await post(base, NON_INIT, { authorization: 'Bearer orb_bob', 'mcp-session-id': stale });
271
+ expect(res.status).toBe(200);
272
+ const newId = res.headers.get('mcp-session-id');
273
+ await res.text();
274
+ expect(newId).toBeTruthy();
275
+ expect(newId).not.toBe(stale);
276
+ // Adoption is recorded old -> new.
277
+ expect(store.register).toHaveBeenCalledWith('orb_bob', expect.objectContaining({ sessionId: newId, adoptedFrom: stale }));
278
+ });
279
+ it('keeps the ORB-1324 404 contract for an unknown id WITHOUT valid auth', async () => {
280
+ const store = fakeStore();
281
+ const { base } = await startWithFakeApi(store.store);
282
+ const res = await post(base, NON_INIT, { authorization: 'Bearer invalid', 'mcp-session-id': 'whatever' });
283
+ expect(res.status).toBe(404);
284
+ expect(res.headers.get('www-authenticate')).toBeNull();
285
+ const bodyText = await res.text();
286
+ expect(bodyText).toMatch(/reinitialize/i);
287
+ // No session was adopted for an unauthenticated request.
288
+ expect(store.register).not.toHaveBeenCalled();
289
+ });
290
+ it('refuses to adopt when the MCP kill-switch is OFF (returns 404, not a session)', async () => {
291
+ const store = fakeStore();
292
+ const { base } = await startWithFakeApi(store.store, fakeApi({ enabled: false }));
293
+ const res = await post(base, NON_INIT, { authorization: 'Bearer orb_carol', 'mcp-session-id': 'sess_stale' });
294
+ expect(res.status).toBe(404);
295
+ await res.text();
296
+ expect(store.register).not.toHaveBeenCalled();
297
+ });
298
+ it('refuses to adopt when the per-user MCP opt-out is OFF (ORB-942, returns 404)', async () => {
299
+ const store = fakeStore();
300
+ // Workspace is enabled but the caller flipped their own users.mcp_enabled
301
+ // off - the shared preflight refuses, so the stale id falls to the 404
302
+ // re-init branch and no session is (re)established.
303
+ const { base } = await startWithFakeApi(store.store, fakeApi({ enabled: true, userMcpEnabled: false }));
304
+ const res = await post(base, NON_INIT, { authorization: 'Bearer orb_dave', 'mcp-session-id': 'sess_stale' });
305
+ expect(res.status).toBe(404);
306
+ await res.text();
307
+ expect(store.register).not.toHaveBeenCalled();
308
+ });
309
+ });
310
+ // ---------------------------------------------------------------------------
311
+ // ORB-1470 - a long-lived MCP session must follow the client's OAuth access-
312
+ // token rotation, not pin itself to the token captured at session creation.
313
+ //
314
+ // Root cause of the field 401s: the transport bound each session's OrbotoClient
315
+ // (+ tool-handler client + SSE bridge) to the bearer seen at creation and
316
+ // reused it for the session's whole life. An OAuth access token has a 1h TTL and
317
+ // the client rotates it; once the pinned token aged out the api answered 401
318
+ // "OAuth access token expired" on the NEXT tool call - even though that request
319
+ // carried a valid refreshed bearer. If the session was initialised with an
320
+ // already-aged cached token, that was only minutes after connect. The fix makes
321
+ // the per-request bearer authoritative via a mutable per-session token holder.
322
+ // ---------------------------------------------------------------------------
323
+ describe('ORB-1470 - per-session bearer follows the client\'s token rotation', () => {
324
+ let api = null;
325
+ afterEach(async () => {
326
+ if (server) {
327
+ const internals = server.__mcp;
328
+ if (internals) {
329
+ for (const s of internals.sessions.values()) {
330
+ try {
331
+ await s.transport.close();
332
+ }
333
+ catch { /* ignore */ }
334
+ }
335
+ }
336
+ }
337
+ await new Promise((r) => (api ? api.close(() => r()) : r()));
338
+ api = null;
339
+ });
340
+ async function startWithFakeApi(store, fake = fakeApi()) {
341
+ api = fake.api;
342
+ await new Promise((r) => api.listen(0, r));
343
+ const { port: apiPort } = api.address();
344
+ server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
345
+ await new Promise((r) => server.listen(0, r));
346
+ const { port } = server.address();
347
+ const internals = server.__mcp;
348
+ return { base: `http://127.0.0.1:${port}`, internals, state: fake.state };
349
+ }
350
+ async function post(base, body, headers) {
351
+ return fetch(`${base}/mcp`, {
352
+ method: 'POST',
353
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
354
+ body,
355
+ });
356
+ }
357
+ it('adopts the bearer presented on each request and forwards the fresh token upstream (no pinned-token 401)', async () => {
358
+ const store = fakeStore();
359
+ const { base, internals, state } = await startWithFakeApi(store.store);
360
+ // 1. Connect: initialise a session with the first access token (v1).
361
+ const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
362
+ expect(initRes.status).toBe(200);
363
+ const sid = initRes.headers.get('mcp-session-id');
364
+ await initRes.text();
365
+ expect(sid).toBeTruthy();
366
+ // The session's mutable holder starts at the creation-time bearer.
367
+ const session = internals.sessions.get(sid);
368
+ expect(session.tokenHolder.current).toBe('orb_tok_v1');
369
+ // 2. The client's OAuth layer rotates its access token (v1 -> v2) and sends
370
+ // the next request on the SAME session id with the fresh bearer.
371
+ const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
372
+ expect(res2.status).toBe(200);
373
+ await res2.text();
374
+ // The session adopted the rotated bearer. Before the fix the session stayed
375
+ // pinned to v1 and would 401 "expired" once v1 aged out.
376
+ expect(session.tokenHolder.current).toBe('orb_tok_v2');
377
+ // End-to-end: the session's actual OrbotoClient now forwards v2 upstream, so
378
+ // the api never sees the stale v1 token again.
379
+ state.seenAuth.length = 0;
380
+ await session.client.get('/system/mcp/status');
381
+ expect(state.seenAuth).toContain('Bearer orb_tok_v2');
382
+ expect(state.seenAuth).not.toContain('Bearer orb_tok_v1');
383
+ });
384
+ it('adopts the current bearer when rehydrating a session after a restart', async () => {
385
+ const store = fakeStore();
386
+ const { base, internals, state } = await startWithFakeApi(store.store);
387
+ // Connect with v1, then simulate an api restart wiping the in-memory map.
388
+ const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
389
+ const sid = initRes.headers.get('mcp-session-id');
390
+ await initRes.text();
391
+ internals.sessions.clear();
392
+ // The client (now on a refreshed v2) reconnects under the SAME id.
393
+ const res = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v1', 'mcp-session-id': sid });
394
+ // ^ rehydration is keyed on the persisted-store owner (token == userId in
395
+ // the fake), so the rehydrate itself uses the presented token. Then a
396
+ // follow-up request rotates to v2.
397
+ expect(res.status).toBe(200);
398
+ await res.text();
399
+ const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
400
+ expect(res2.status).toBe(200);
401
+ await res2.text();
402
+ const session = internals.sessions.get(sid);
403
+ expect(session.tokenHolder.current).toBe('orb_tok_v2');
404
+ state.seenAuth.length = 0;
405
+ await session.client.get('/system/mcp/status');
406
+ expect(state.seenAuth).toContain('Bearer orb_tok_v2');
407
+ });
408
+ });
package/dist/index.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ORB-244 Phase A - entry point for `@orboto/mcp`.
4
+ *
5
+ * Two transport modes, picked by env:
6
+ * ORBOTO_MCP_TRANSPORT=stdio (default) - JSON-RPC over stdin/stdout,
7
+ * used by the Local-Proxy delivery variant. Claude Desktop spawns
8
+ * this process via the `claude_desktop_config.json` snippet and
9
+ * talks over its own stdio pair.
10
+ * ORBOTO_MCP_TRANSPORT=http - Streamable HTTP per MCP
11
+ * spec. Used by the Self-Hosted-inline + Cloud-Managed variants
12
+ * (separate container listening on a port the reverse proxy maps
13
+ * to `/mcp`). Sessions carry `mcp-session-id` for server→client
14
+ * notifications.
15
+ *
16
+ * Config (env-only - no config file):
17
+ * ORBOTO_API_URL required - base URL of the orboto API
18
+ * ORBOTO_API_KEY optional - `orb_*` API key with `mcp:use`
19
+ * scope (stdio mode). Service-account fallback;
20
+ * when omitted, stdio mode bootstraps via OAuth
21
+ * (ORB-943) so a desktop user connects through the
22
+ * workspace login without pasting a token. Per-
23
+ * session bearer is read from the Authorization
24
+ * header in http mode.
25
+ * ORBOTO_AUTH optional - `pat` | `oauth`. Defaults to `pat`
26
+ * when ORBOTO_API_KEY is set, else `oauth`. Force
27
+ * `oauth` to run the browser bootstrap even with a
28
+ * key present.
29
+ * ORBOTO_MCP_TRANSPORT optional - `stdio` (default) | `http`
30
+ * ORBOTO_MCP_PORT optional - port for http transport, default 3100
31
+ * ORBOTO_MCP_CLIENT optional - client hint for User-Agent (e.g.
32
+ * `claude-desktop`, `cursor`).
33
+ */
34
+ import { buildOrbotoMcpServer } from './server.js';
35
+ import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
36
+ import { bootstrapOAuth } from './oauth-bootstrap.js';
37
+ function requireEnv(name) {
38
+ const v = process.env[name];
39
+ if (!v || v.length === 0) {
40
+ process.stderr.write(`[orboto-mcp] missing required env var: ${name}\n`);
41
+ process.exit(2);
42
+ }
43
+ return v;
44
+ }
45
+ async function main() {
46
+ const transport = (process.env.ORBOTO_MCP_TRANSPORT ?? 'stdio').toLowerCase();
47
+ if (transport === 'stdio') {
48
+ // Local-Proxy mode - one MCP client, one process, one identity. The
49
+ // identity is either a static `orb_*` PAT (service-account fallback) OR,
50
+ // when no key is configured, an OAuth session bootstrapped through the
51
+ // workspace login (ORB-943) so a desktop user never pastes a token. All
52
+ // config read at boot; no per-request auth needed because the only user of
53
+ // this stdio pair is the client that spawned us.
54
+ const baseUrl = requireEnv('ORBOTO_API_URL');
55
+ const apiKey = process.env.ORBOTO_API_KEY;
56
+ const userAgentSuffix = process.env.ORBOTO_MCP_CLIENT;
57
+ const authMode = (process.env.ORBOTO_AUTH ?? (apiKey ? 'pat' : 'oauth')).toLowerCase();
58
+ if (authMode === 'pat' && !apiKey) {
59
+ // eslint-disable-next-line no-console
60
+ console.error('[orboto-mcp] ORBOTO_AUTH=pat requires ORBOTO_API_KEY. Unset it to use OAuth, or provide a key.');
61
+ process.exit(2);
62
+ }
63
+ // clientConfig carries either the PAT or the OAuth token provider; both
64
+ // shapes satisfy OrbotoClientConfig.
65
+ let clientConfig;
66
+ if (authMode === 'oauth') {
67
+ try {
68
+ const tokenProvider = await bootstrapOAuth({ apiBaseUrl: baseUrl });
69
+ clientConfig = { baseUrl, userAgentSuffix, tokenProvider };
70
+ }
71
+ catch (err) {
72
+ // eslint-disable-next-line no-console
73
+ console.error(`[orboto-mcp] OAuth bootstrap failed: ${err.message}`);
74
+ process.exit(1);
75
+ }
76
+ }
77
+ else {
78
+ clientConfig = { baseUrl, userAgentSuffix, apiKey };
79
+ }
80
+ // Preflight BEFORE spinning up the transport - so a
81
+ // mis-configured install fails loudly to stderr instead of
82
+ // silently hanging on stdin waiting for JSON-RPC frames.
83
+ const preflightClient = new OrbotoClient(clientConfig);
84
+ try {
85
+ const { userEmail } = await preflightMcpSession(preflightClient);
86
+ // eslint-disable-next-line no-console
87
+ console.error(`[orboto-mcp] authenticated as ${userEmail} (${authMode}) → ${baseUrl}`);
88
+ }
89
+ catch (err) {
90
+ // eslint-disable-next-line no-console
91
+ console.error(`[orboto-mcp] ${err.message}`);
92
+ process.exit(1);
93
+ }
94
+ const server = await buildOrbotoMcpServer(clientConfig);
95
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
96
+ const stdio = new StdioServerTransport();
97
+ await server.connect(stdio);
98
+ // The SDK writes its own "connected" log to stderr; we don't
99
+ // echo anything here - stdout is reserved for JSON-RPC frames
100
+ // and an accidental console.log would corrupt the protocol.
101
+ return;
102
+ }
103
+ if (transport === 'http') {
104
+ // Self-Hosted + Cloud-Managed mode. The bearer token comes from
105
+ // the caller (Claude Desktop / Cursor) on every POST - a per-
106
+ // session server is built so each session carries its own
107
+ // API-key scoped OrbotoClient.
108
+ const port = Number(process.env.ORBOTO_MCP_PORT ?? '3100');
109
+ const baseUrl = requireEnv('ORBOTO_API_URL');
110
+ const { createHttpServer } = await import('./http-transport.js');
111
+ const httpServer = createHttpServer({ baseUrl });
112
+ httpServer.listen(port, () => {
113
+ // stderr so self-hosted operators can tail the container log
114
+ // without parsing the JSON on stdout (stdio mode).
115
+ // eslint-disable-next-line no-console
116
+ console.error(`[orboto-mcp] http listening on :${port} → ${baseUrl}`);
117
+ });
118
+ return;
119
+ }
120
+ // eslint-disable-next-line no-console
121
+ console.error(`[orboto-mcp] unknown ORBOTO_MCP_TRANSPORT=${transport} (expected 'stdio' or 'http')`);
122
+ process.exit(1);
123
+ }
124
+ main().catch((err) => {
125
+ // eslint-disable-next-line no-console
126
+ console.error('[orboto-mcp] fatal:', err);
127
+ process.exit(1);
128
+ });