@orboto/mcp 0.192.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
@@ -26,10 +26,6 @@ describe('ORB-1175 - stale MCP session recovery', () => {
26
26
  body: NON_INIT,
27
27
  });
28
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
29
  expect(res.headers.get('www-authenticate')).toBeNull();
34
30
  const body = await res.json();
35
31
  expect(body.error).toMatch(/reinitialize/i);
@@ -68,7 +64,6 @@ describe('ORB-1424 - GET /mcp OAuth-discovery probe', () => {
68
64
  });
69
65
  expect(res.status).toBe(405);
70
66
  expect(res.headers.get('allow')).toBe('POST, DELETE');
71
- // A held token needs no auth challenge on the 405.
72
67
  expect(res.headers.get('www-authenticate')).toBeNull();
73
68
  });
74
69
  });
@@ -96,7 +91,6 @@ describe('ORB-941 - graceful close of in-flight MCP sessions on kill-switch', ()
96
91
  expect(b.log).toHaveBeenCalledOnce();
97
92
  expect(a.close).toHaveBeenCalledOnce();
98
93
  expect(b.close).toHaveBeenCalledOnce();
99
- // The reason is surfaced to the client in the notification payload.
100
94
  expect(a.log.mock.calls[0][0]).toMatchObject({ level: 'warning' });
101
95
  expect(a.log.mock.calls[0][0].data).toContain('disabled by admin.');
102
96
  });
@@ -108,13 +102,9 @@ describe('ORB-941 - graceful close of in-flight MCP sessions on kill-switch', ()
108
102
  expect(s.close).toHaveBeenCalledOnce();
109
103
  });
110
104
  });
111
- // ---------------------------------------------------------------------------
112
- // ORB-1353 - session resilience: persist across restarts + auto-adopt.
113
- // ---------------------------------------------------------------------------
114
105
  describe('ORB-1353 - classifyUnknownSession (pure decision)', () => {
115
106
  it('keeps the 404 re-init contract when auth is invalid', () => {
116
107
  expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: false })).toBe('reinit-404');
117
- // Even a "persisted" id must not resurrect without valid auth.
118
108
  expect(classifyUnknownSession({ hasValidAuth: false, isPersistedForCaller: true })).toBe('reinit-404');
119
109
  });
120
110
  it('rehydrates a persisted session owned by the caller (layer 1)', () => {
@@ -134,25 +124,17 @@ describe('ORB-1353 - clientInfoLabel', () => {
134
124
  expect(clientInfoLabel(null)).toBeUndefined();
135
125
  });
136
126
  });
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
127
  function fakeApi(opts = {}) {
142
128
  const state = {
143
129
  enabled: opts.enabled ?? true,
144
130
  mcpUseGranted: opts.mcpUseGranted ?? true,
145
- // ORB-942 - per-user MCP opt-out. Defaults on so existing flows are unaffected.
146
131
  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
132
  seenAuth: [],
150
133
  };
151
134
  const api = createNodeServer((req, res) => {
152
135
  const url = req.url ?? '';
153
136
  if (url.startsWith('/system/mcp/status')) {
154
137
  state.seenAuth.push((req.headers.authorization ?? ''));
155
- // A token of `Bearer invalid` models an expired/invalid credential.
156
138
  if ((req.headers.authorization ?? '') === 'Bearer invalid') {
157
139
  res.writeHead(401, { 'content-type': 'application/json' });
158
140
  res.end(JSON.stringify({ error: 'invalid token' }));
@@ -173,7 +155,6 @@ function fakeApi(opts = {}) {
173
155
  return;
174
156
  }
175
157
  if (url.startsWith('/sse/mcp-events')) {
176
- // Empty stream - the bridge reconnects on close, cleared by session close.
177
158
  res.writeHead(200, { 'content-type': 'text/event-stream' });
178
159
  res.end();
179
160
  return;
@@ -208,7 +189,6 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
208
189
  let api = null;
209
190
  const cleanups = [];
210
191
  afterEach(async () => {
211
- // Close any live MCP sessions so their event-bridge retry timers don't leak.
212
192
  for (const c of cleanups.splice(0))
213
193
  c();
214
194
  if (server) {
@@ -245,18 +225,13 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
245
225
  it('rehydrates the SAME session id after an in-memory restart (layer 1)', async () => {
246
226
  const store = fakeStore();
247
227
  const { base, internals } = await startWithFakeApi(store.store);
248
- // 1. Initialise a session and capture its id.
249
228
  const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_alice' });
250
229
  expect(initRes.status).toBe(200);
251
230
  const sid = initRes.headers.get('mcp-session-id');
252
231
  await initRes.text();
253
232
  expect(sid).toBeTruthy();
254
- // The freshly-minted session was persisted.
255
233
  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
234
  internals.sessions.clear();
259
- // 3. The next call with the OLD id + valid auth rehydrates under the SAME id.
260
235
  const res = await post(base, NON_INIT, { authorization: 'Bearer orb_alice', 'mcp-session-id': sid });
261
236
  expect(res.status).toBe(200);
262
237
  expect(res.headers.get('mcp-session-id')).toBe(sid);
@@ -273,7 +248,6 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
273
248
  await res.text();
274
249
  expect(newId).toBeTruthy();
275
250
  expect(newId).not.toBe(stale);
276
- // Adoption is recorded old -> new.
277
251
  expect(store.register).toHaveBeenCalledWith('orb_bob', expect.objectContaining({ sessionId: newId, adoptedFrom: stale }));
278
252
  });
279
253
  it('keeps the ORB-1324 404 contract for an unknown id WITHOUT valid auth', async () => {
@@ -284,7 +258,6 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
284
258
  expect(res.headers.get('www-authenticate')).toBeNull();
285
259
  const bodyText = await res.text();
286
260
  expect(bodyText).toMatch(/reinitialize/i);
287
- // No session was adopted for an unauthenticated request.
288
261
  expect(store.register).not.toHaveBeenCalled();
289
262
  });
290
263
  it('refuses to adopt when the MCP kill-switch is OFF (returns 404, not a session)', async () => {
@@ -297,9 +270,6 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
297
270
  });
298
271
  it('refuses to adopt when the per-user MCP opt-out is OFF (ORB-942, returns 404)', async () => {
299
272
  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
273
  const { base } = await startWithFakeApi(store.store, fakeApi({ enabled: true, userMcpEnabled: false }));
304
274
  const res = await post(base, NON_INIT, { authorization: 'Bearer orb_dave', 'mcp-session-id': 'sess_stale' });
305
275
  expect(res.status).toBe(404);
@@ -307,19 +277,6 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
307
277
  expect(store.register).not.toHaveBeenCalled();
308
278
  });
309
279
  });
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
280
  describe('ORB-1470 - per-session bearer follows the client\'s token rotation', () => {
324
281
  let api = null;
325
282
  afterEach(async () => {
@@ -357,25 +314,17 @@ describe('ORB-1470 - per-session bearer follows the client\'s token rotation', (
357
314
  it('adopts the bearer presented on each request and forwards the fresh token upstream (no pinned-token 401)', async () => {
358
315
  const store = fakeStore();
359
316
  const { base, internals, state } = await startWithFakeApi(store.store);
360
- // 1. Connect: initialise a session with the first access token (v1).
361
317
  const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
362
318
  expect(initRes.status).toBe(200);
363
319
  const sid = initRes.headers.get('mcp-session-id');
364
320
  await initRes.text();
365
321
  expect(sid).toBeTruthy();
366
- // The session's mutable holder starts at the creation-time bearer.
367
322
  const session = internals.sessions.get(sid);
368
323
  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
324
  const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
372
325
  expect(res2.status).toBe(200);
373
326
  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
327
  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
328
  state.seenAuth.length = 0;
380
329
  await session.client.get('/system/mcp/status');
381
330
  expect(state.seenAuth).toContain('Bearer orb_tok_v2');
@@ -384,16 +333,11 @@ describe('ORB-1470 - per-session bearer follows the client\'s token rotation', (
384
333
  it('adopts the current bearer when rehydrating a session after a restart', async () => {
385
334
  const store = fakeStore();
386
335
  const { base, internals, state } = await startWithFakeApi(store.store);
387
- // Connect with v1, then simulate an api restart wiping the in-memory map.
388
336
  const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_tok_v1' });
389
337
  const sid = initRes.headers.get('mcp-session-id');
390
338
  await initRes.text();
391
339
  internals.sessions.clear();
392
- // The client (now on a refreshed v2) reconnects under the SAME id.
393
340
  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
341
  expect(res.status).toBe(200);
398
342
  await res.text();
399
343
  const res2 = await post(base, NON_INIT, { authorization: 'Bearer orb_tok_v2', 'mcp-session-id': sid });
package/dist/index.js CHANGED
@@ -2,34 +2,7 @@
2
2
  /**
3
3
  * ORB-244 Phase A - entry point for `@orboto/mcp`.
4
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`).
5
+ * @see ORB-943
33
6
  */
34
7
  import { buildOrbotoMcpServer } from './server.js';
35
8
  import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
@@ -45,12 +18,6 @@ function requireEnv(name) {
45
18
  async function main() {
46
19
  const transport = (process.env.ORBOTO_MCP_TRANSPORT ?? 'stdio').toLowerCase();
47
20
  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
21
  const baseUrl = requireEnv('ORBOTO_API_URL');
55
22
  const apiKey = process.env.ORBOTO_API_KEY;
56
23
  const userAgentSuffix = process.env.ORBOTO_MCP_CLIENT;
@@ -60,8 +27,6 @@ async function main() {
60
27
  console.error('[orboto-mcp] ORBOTO_AUTH=pat requires ORBOTO_API_KEY. Unset it to use OAuth, or provide a key.');
61
28
  process.exit(2);
62
29
  }
63
- // clientConfig carries either the PAT or the OAuth token provider; both
64
- // shapes satisfy OrbotoClientConfig.
65
30
  let clientConfig;
66
31
  if (authMode === 'oauth') {
67
32
  try {
@@ -77,9 +42,6 @@ async function main() {
77
42
  else {
78
43
  clientConfig = { baseUrl, userAgentSuffix, apiKey };
79
44
  }
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
45
  const preflightClient = new OrbotoClient(clientConfig);
84
46
  try {
85
47
  const { userEmail } = await preflightMcpSession(preflightClient);
@@ -95,23 +57,14 @@ async function main() {
95
57
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
96
58
  const stdio = new StdioServerTransport();
97
59
  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
60
  return;
102
61
  }
103
62
  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
63
  const port = Number(process.env.ORBOTO_MCP_PORT ?? '3100');
109
64
  const baseUrl = requireEnv('ORBOTO_API_URL');
110
65
  const { createHttpServer } = await import('./http-transport.js');
111
66
  const httpServer = createHttpServer({ baseUrl });
112
67
  httpServer.listen(port, () => {
113
- // stderr so self-hosted operators can tail the container log
114
- // without parsing the JSON on stdout (stdio mode).
115
68
  // eslint-disable-next-line no-console
116
69
  console.error(`[orboto-mcp] http listening on :${port} → ${baseUrl}`);
117
70
  });
@@ -1,60 +1,7 @@
1
1
  /**
2
2
  * ORB-1692 - strict tool inputs + the aliases agents actually send.
3
3
  *
4
- * Measured across 32 transcripts: 55.6% of all MCP errors were "Required"
5
- * validation failures where the agent guessed a plausible-but-wrong
6
- * parameter name (`body` for `text`, `parentKey` for `parentTicketKey`,
7
- * ...). Worse, zod strips unknown keys by default, so a wrong key on an
8
- * otherwise-valid call VANISHED silently - `create_ticket` with
9
- * `parentKey` created ORB-1684 with no parent and no warning.
10
- *
11
- * Fix, applied centrally at registration (with-metrics `reg()`):
12
- * 1. every tool input becomes `.strict()` - an unknown key errors,
13
- * naming the offender;
14
- * 2. the measured aliases resolve silently to the canonical field via a
15
- * GUARDED global rename: `alias -> canonical` fires only when the
16
- * canonical field exists in that tool's shape, the alias does not,
17
- * and the caller did not also send the canonical - so a tool whose
18
- * REAL field is `body` (agent-instruction blocks) is never touched;
19
- * 3. `orboto_update_ticket` folds flat patch fields into `patch` when no
20
- * `patch` was sent - agents routinely write `{ticketKey, description}`.
21
- *
22
- * ORB-1817 - "we do not force keys that make no sense - key and query are
23
- * sensible names" (operator). Extends the alias table with the sensible
24
- * spellings a caller actually reaches for (`key`, `query`, `projectId`,
25
- * ...) plus one DYNAMIC alias - `id` resolves to the tool's own single
26
- * id-shaped parameter (`ticketKey` / `docId` / `milestone` / `commentId`)
27
- * only when exactly one of those exists in that tool's shape - and adds a
28
- * teaching error: when a call still can't be resolved, the thrown message
29
- * is a structured JSON block naming every parameter the tool accepts plus
30
- * a Levenshtein<=2 guess for each unrecognized key (same distance the mjs
31
- * wrapper's `suggestFlag` uses for `--flag` typos). The failure is also
32
- * logged through the same `/admin/mcp/instrument` path a handler error
33
- * uses - see the module doc on `mcp-instrument.ts` for why that needs a
34
- * dedicated leaf module instead of importing from with-metrics.ts.
35
- *
36
- * SDK mechanics: `registerTool` accepts either a raw shape or a zod
37
- * schema. A top-level `z.preprocess(...)` has no `.shape`, which would
38
- * make the SDK advertise an EMPTY input schema in tools/list. Stamping
39
- * the inner object's `.shape` onto the effects schema keeps the
40
- * advertisement byte-identical to the strict object (verified: the SDK's
41
- * `normalizeObjectSchema` only checks `.shape !== undefined`, and its
42
- * json-schema emitter unwraps effects with pipeStrategy "input").
43
- *
44
- * ORB-1817 - the preprocess callback is also where the teaching error is
45
- * thrown (verified against @modelcontextprotocol/sdk 1.29's zod-compat +
46
- * server/mcp.js): a `preprocess` effect calls `effect.transform(data,
47
- * checkCtx)` BEFORE the inner schema ever parses, and unlike `refine` /
48
- * `transform`, a synchronous throw from inside it is NOT caught by zod -
49
- * it propagates out of `ZodEffects._parse`, out of the SDK's
50
- * `safeParseAsync` (converted to a rejected promise, since that's an
51
- * `async function`), and lands in the SDK's own `CallToolRequestSchema`
52
- * handler's outer try/catch, which for a plain (non-McpError) `Error`
53
- * returns `{isError: true, content: [{type: 'text', text: error.message}]}`
54
- * with NO extra wrapping. That is what lets us hand back exactly our
55
- * structured JSON with nothing else attached - `.superRefine()` cannot do
56
- * this (it never even runs once the base object parse has already failed,
57
- * which is the common case: a missing-required or unrecognized-key input).
4
+ * @see ORB-1684, ORB-1817
58
5
  */
59
6
  import { z } from 'zod';
60
7
  import { postLogEntry, redactSecrets } from './mcp-instrument.js';
@@ -73,16 +20,12 @@ export const GLOBAL_INPUT_ALIASES = {
73
20
  milestoneKey: 'milestone',
74
21
  ticket: 'ticketKey',
75
22
  project: 'projectKey',
76
- // ORB-1817 - measured 2026-09-03: both cost one retry in the same
77
- // session. "key" and "query" are what a reasonable caller types.
78
23
  key: 'ticketKey',
79
24
  query: 'oql',
80
25
  projectId: 'projectKey',
81
26
  milestoneId: 'milestone',
82
27
  comment: 'text',
83
28
  message: 'text',
84
- // Bulk tools all take `ticketKeys` (plural) - singular-sounding `keys`
85
- // is the natural typo/guess.
86
29
  keys: 'ticketKeys',
87
30
  max: 'limit',
88
31
  page: 'cursor',
@@ -112,7 +55,6 @@ function applyAliases(toolName, shape, value) {
112
55
  out = { ...rest, [canonical]: aliased };
113
56
  }
114
57
  }
115
- // ORB-1817 - `id` -> the tool's single id-shaped parameter.
116
58
  if ('id' in out && !('id' in shape)) {
117
59
  const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out));
118
60
  if (candidates.length === 1) {
@@ -121,9 +63,6 @@ function applyAliases(toolName, shape, value) {
121
63
  out = { ...rest, [canonical]: id };
122
64
  }
123
65
  }
124
- // update_ticket: flat fields become an implicit patch. Only when the
125
- // caller sent no patch at all - a partial patch plus flat extras stays
126
- // an error (ambiguous intent must not be guessed).
127
66
  if (toolName === 'orboto_update_ticket' && !('patch' in out)) {
128
67
  const patch = {};
129
68
  const rest = {};
@@ -142,10 +81,6 @@ function applyAliases(toolName, shape, value) {
142
81
  }
143
82
  return out;
144
83
  }
145
- // ---------------------------------------------------------------------------
146
- // ORB-1817 - teaching error: structured `expected` block + closest-name
147
- // guess, built when the strict + alias-resolved value STILL doesn't parse.
148
- // ---------------------------------------------------------------------------
149
84
  /** Levenshtein-1/2 match, same algorithm skills/orboto/scripts/orboto.mjs
150
85
  * uses for `--flag` typos (`suggestFlag` / `levenshtein`) - keep them in
151
86
  * sync if the distance metric ever changes. */
@@ -247,12 +182,7 @@ function buildTeachingError(toolName, shape, issues) {
247
182
  * validates against. Returns a schema whose `.shape` is stamped so
248
183
  * tools/list advertisement stays identical to the plain object form.
249
184
  *
250
- * `client` + `clientHint` are optional (tests construct schemas without
251
- * them) - when present, a validation failure that survives aliasing is
252
- * logged through the SAME `/admin/mcp/instrument` path a handler error
253
- * uses (ORB-1817 Part C), because the SDK validates input BEFORE
254
- * with-metrics.ts's handler wrapper ever runs - this preprocess step is
255
- * the only place that ever sees the failure.
185
+ * @see ORB-1817
256
186
  */
257
187
  export function buildStrictInputSchema(toolName, shape, client, clientHint) {
258
188
  const inner = z.object(shape).strict();
@@ -273,12 +203,8 @@ export function buildStrictInputSchema(toolName, shape, client, clientHint) {
273
203
  clientHint,
274
204
  });
275
205
  }
276
- // See the module doc: thrown here (inside a "preprocess" effect, not
277
- // a refinement/transform), this is NOT caught by zod - it becomes the
278
- // exact isError text the caller sees.
279
206
  throw new Error(message);
280
207
  }, inner);
281
- // SDK contract (see module doc): .shape must exist for advertisement.
282
208
  effects.shape = inner.shape;
283
209
  return effects;
284
210
  }
@@ -2,18 +2,7 @@
2
2
  * ORB-1692 / ORB-1817 - strict inputs + measured aliases + the teaching
3
3
  * error.
4
4
  *
5
- * Table-driven against the REAL tool configs: each alias from the
6
- * measurement table must resolve to the canonical field, unknown keys
7
- * must error naming the offender, and the ORB-1684 silent-drop case
8
- * (create_ticket with parentKey) must never come back.
9
- *
10
- * ORB-1817 - a call that still can't be resolved after aliasing now
11
- * THROWS (from every parse entry point, `.safeParse()` included - see
12
- * the module doc on `buildStrictInputSchema` for why: the teaching error
13
- * is thrown from inside a zod "preprocess" effect, which runs before the
14
- * sync/async branch even exists). That's an intentional behaviour change
15
- * from plain zod (whose `.safeParse()` never throws) - every test below
16
- * that used to check `parsed.success === false` now checks the throw.
5
+ * @see ORB-1684
17
6
  */
18
7
  import { describe, it, expect, vi } from 'vitest';
19
8
  import { z } from 'zod';
@@ -105,7 +94,6 @@ describe('alias guard: canonical-field collisions are never renamed', () => {
105
94
  }
106
95
  });
107
96
  it('alias is ignored when the caller also sent the canonical field', () => {
108
- // both present: no rename happens, so `body` is an unknown key -> error
109
97
  const body = throwsWith(schemaFor('orboto_comment', commentToolConfig), { ticketKey: 'ORB-1', text: 'real', body: 'stray' });
110
98
  expect(body.unrecognized).toEqual(['body']);
111
99
  });
@@ -121,7 +109,6 @@ describe('ORB-1817: extended alias table', () => {
121
109
  { tool: 'orboto_bulk_move_tickets', config: bulkMoveTicketsToolConfig, send: { keys: ['ORB-1', 'ORB-2'], statusCategory: 'done' }, expectKey: 'ticketKeys', expectValue: ['ORB-1', 'ORB-2'] },
122
110
  { tool: 'orboto_query', config: queryToolConfig, send: { oql: 'project = ORB', max: 10 }, expectKey: 'limit', expectValue: 10 },
123
111
  { tool: 'orboto_query', config: queryToolConfig, send: { oql: 'project = ORB', page: 'c1' }, expectKey: 'cursor', expectValue: 'c1' },
124
- // `id` -> the tool's single id-shaped parameter (ticketKey here).
125
112
  { tool: 'orboto_get_ticket', config: getTicketToolConfig, send: { id: 'ORB-1' }, expectKey: 'ticketKey', expectValue: 'ORB-1' },
126
113
  { tool: 'orboto_get_doc', config: getDocToolConfig, send: { id: 'ORB-D12' }, expectKey: 'docId', expectValue: 'ORB-D12' },
127
114
  ];
@@ -136,7 +123,6 @@ describe('ORB-1817: extended alias table', () => {
136
123
  });
137
124
  }
138
125
  it('`id` is left alone (and errors) when the tool has more than one id-shaped parameter', () => {
139
- // orboto_update_comment has BOTH ticketKey and commentId - ambiguous.
140
126
  const body = throwsWith(schemaFor('orboto_update_comment', updateCommentToolConfig), { id: 'ORB-1', text: 'hi' });
141
127
  expect(body.unrecognized).toEqual(['id']);
142
128
  });
@@ -23,12 +23,10 @@ describe('assembleInstructions (ORB-1177)', () => {
23
23
  expect(out).toContain('orboto://rules');
24
24
  expect(out).toContain('orboto_session_start');
25
25
  expect(out).toContain('truncated');
26
- // Every kept rule line is a COMPLETE original line (no mid-line cut).
27
26
  const body = out.slice(`${head}\n\n${HEADING}`.length).split('\n\n[... rules truncated')[0];
28
27
  const kept = body.length ? body.split('\n') : [];
29
28
  for (const line of kept)
30
29
  expect(lines).toContain(line);
31
- // And it actually kept fewer than all lines (proves truncation happened).
32
30
  expect(kept.length).toBeLessThan(lines.length);
33
31
  });
34
32
  });
@@ -1,17 +1,6 @@
1
1
  /**
2
2
  * ORB-1521 - manifest-size measurement + regression ratchet (epic
3
3
  * ORB-1517).
4
- *
5
- * Measures the REAL `tools/list` payload an eager-loading client pays
6
- * at connect time - through an actual MCP client over an in-memory
7
- * transport, so the numbers include the SDK's zod->JSON-schema
8
- * conversion, exactly what Codex/Cursor/Claude Desktop receive.
9
- *
10
- * The curated assertion is the RATCHET (mirroring the dark-mode / i18n
11
- * shrink-only checks): if someone re-registers the tail into the
12
- * default manifest - or the curated set quietly balloons - this fails
13
- * before a release ships the token bill. Raising CURATED_MAX_CHARS is a
14
- * conscious decision to grow the default connect cost, not a formality.
15
4
  */
16
5
  import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
17
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -37,34 +26,9 @@ afterEach(() => { vi.restoreAllMocks(); });
37
26
  * 20 %. Shrink-only: raising either number is a conscious decision to
38
27
  * grow every session's connect cost.
39
28
  *
40
- * ORB-1805, measured 2026-09-01 with the schema diet + the compact
41
- * advertised `__truncation` block: minimal = 12 tools / 9,440 chars;
42
- * curated = 31 tools / 27,945 chars (was 34,015); full = 175 tools /
43
- * 142,366 chars (was 152,348).
44
- *
45
- * ORB-1669, landed 2026-09-04: every tool carries an MCP `annotations`
46
- * block (readOnlyHint / destructiveHint / idempotentHint) - clients use
47
- * them for confirmation UX, so they are spec conformance, not payload
48
- * fat. Measured cost: minimal +688 chars (12 tools), curated +1,276,
49
- * full +9,836 (175 tools, ~56 chars each). FULL_MAX_CHARS and
50
- * MINIMAL_MAX_TOKENS were raised by exactly that, deliberately.
51
- *
52
- * ORB-1910 (2026-09-05): orboto_report_feedback joins the curated set (32
53
- * tools) - an agent that hits a bug must be able to report it without
54
- * ?toolset=full. Measured curated = 30,652 chars after trimming the tool's
55
- * schema to the minimum (one describe() on `page`); ceiling re-pinned to
56
- * 32,500 (~6 % headroom), deliberately, for that one addition.
29
+ * @see ORB-1805, ORB-1669, ORB-1910
57
30
  */
58
- // ORB-1959 - re-pinned at 37,500 (measured 36,714): the four agent
59
- // coordination tools (messages, notify, broadcast, heartbeat) joined the
60
- // curated tier so an agent on the DEFAULT connection can fetch, act on and
61
- // ack its mail - the binding workspace rule the old manifest made impossible
62
- // to follow. Their descriptions were cut to the load-bearing facts first
63
- // (286 chars); the remaining ~4.2k chars are input/output schemas and the
64
- // per-tool keys the SDK emits. Decision by the operator, 2026-09-06.
65
31
  const CURATED_MAX_CHARS = 37_500;
66
- // ORB-2000 - orboto_draft_customer_reply joins the full tier (measured
67
- // 155,669 chars with it); ceiling re-pinned with ~6 % headroom.
68
32
  const FULL_MAX_CHARS = 165_000;
69
33
  /**
70
34
  * ORB-1805 - the estimator the ticket measured the failure with
@@ -85,22 +49,8 @@ const MINIMAL_MAX_TOKENS = 3_100;
85
49
  /**
86
50
  * The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
87
51
  * block has its own budget, enforced in instructions-budget.test.ts).
88
- *
89
- * Why this is not the ticket's aspirational 6,000: measured 2026-09-01,
90
- * the curated manifest with EVERY description and title stripped out is
91
- * still 17,379 chars (~4,827 tokens) - 31 tools' names, parameter names,
92
- * types, enums, plus ~2,700 chars of per-tool `$schema` and `execution`
93
- * keys the MCP SDK emits and we do not control. A 6,000-token ceiling
94
- * would leave ~4,200 chars for all 31 tools' text combined, i.e. the
95
- * one-line summaries and NOTHING else - no "Key (ORB-M3), name, or
96
- * UUID", no "YYYY-MM-DD", no "Default: task". Those pay for themselves
97
- * in avoided failed calls. The tier that genuinely fits a small window
98
- * is `minimal`; curated is the desktop-client default, and this ceiling
99
- * holds the 18 % the diet won.
100
52
  */
101
- // ORB-1910 - re-pinned with CURATED_MAX_CHARS (32,500 / 3.6): the curated set
102
- // gained orboto_report_feedback; measured 8,514 estimated tokens.
103
- const CURATED_SCHEMA_MAX_TOKENS = 10_450; // ORB-1959: measured 10,198 with the coordination tools
53
+ const CURATED_SCHEMA_MAX_TOKENS = 10_450;
104
54
  async function measuredManifest(toolset) {
105
55
  const server = await buildOrbotoMcpServer({
106
56
  baseUrl: 'https://orboto.example.com',
@@ -111,9 +61,6 @@ async function measuredManifest(toolset) {
111
61
  const client = new Client({ name: 'manifest-measure', version: '0.0.0' });
112
62
  await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
113
63
  try {
114
- // Walk pagination defensively; the SDK serves all tools in one page
115
- // today but the measurement must not silently undercount if that
116
- // changes.
117
64
  let cursor;
118
65
  const tools = [];
119
66
  do {
@@ -121,9 +68,6 @@ async function measuredManifest(toolset) {
121
68
  tools.push(...page.tools);
122
69
  cursor = page.nextCursor;
123
70
  } while (cursor);
124
- // `_instructions` verified against @modelcontextprotocol/sdk 1.29.
125
- // Assert non-empty at the call sites so an SDK rename fails loudly
126
- // instead of scoring the block as free.
127
71
  const instructions = String(server.server._instructions ?? '');
128
72
  return { count: tools.length, chars: JSON.stringify(tools).length, instructionsChars: instructions.length };
129
73
  }
@@ -137,9 +81,6 @@ describe('ORB-1521 - eager-load manifest size', () => {
137
81
  const { count, chars } = await measuredManifest();
138
82
  // eslint-disable-next-line no-console
139
83
  console.log(`[manifest-size] curated: ${count} tools, ${chars} chars (~${estTokens(chars)} tokens)`);
140
- // 31 = the ORB-1741-dieted set + the two ORB-1694 bulk tools (each
141
- // REPLACES dozens of per-call responses, so they earn their slot);
142
- // ORB-1910 added report_feedback, ORB-1959 the four coordination tools.
143
84
  expect(count).toBeLessThanOrEqual(36);
144
85
  expect(chars).toBeLessThanOrEqual(CURATED_MAX_CHARS);
145
86
  expect(estTokens(chars)).toBeLessThanOrEqual(CURATED_SCHEMA_MAX_TOKENS);
@@ -150,11 +91,7 @@ describe('ORB-1521 - eager-load manifest size', () => {
150
91
  // eslint-disable-next-line no-console
151
92
  console.log(`[manifest-size] full: ${full.count} tools, ${full.chars} chars (~${estTokens(full.chars)} tokens)`);
152
93
  expect(full.count).toBeGreaterThan(150);
153
- // ORB-1741 - the full manifest is ratcheted too: the diet's savings
154
- // live mostly here (per-tool description essays), so this is where
155
- // regression would land first.
156
94
  expect(full.chars).toBeLessThanOrEqual(FULL_MAX_CHARS);
157
- // The reduction claim the epic makes - keep it honest in CI.
158
95
  expect(full.chars / curated.chars).toBeGreaterThan(3);
159
96
  });
160
97
  });
@@ -174,7 +111,7 @@ describe('ORB-1805 - minimal manifest fits a small context window', () => {
174
111
  + ` + instructions ${instructionsChars} chars (~${estTokens(instructionsChars)} tokens)`
175
112
  + ` = ~${estTokens(total)} tokens`);
176
113
  expect(count).toBe(12);
177
- expect(instructionsChars).toBeGreaterThan(100); // SDK rename guard
114
+ expect(instructionsChars).toBeGreaterThan(100);
178
115
  expect(estTokens(total)).toBeLessThanOrEqual(MINIMAL_MAX_TOKENS);
179
116
  });
180
117
  it('minimal is a strict subset of curated, which is a strict subset of full', async () => {
@@ -186,12 +123,6 @@ describe('ORB-1805 - minimal manifest fits a small context window', () => {
186
123
  expect(minimal.chars).toBeLessThan(curated.chars);
187
124
  });
188
125
  it('minimal instructions are a CONSTANT - a long workspace rule set cannot grow them', async () => {
189
- // The tier ships the head only: no workspace rules block, so a
190
- // workspace with a 200-line rule set costs the same as an empty one.
191
- // (The rules are not lost - orboto_session_start returns them, on
192
- // the caller's budget rather than as a connect-time tax.) Curated,
193
- // by contrast, embeds them up to its 4k budget - assert BOTH so this
194
- // stays a real difference and not a coincidence of the fixture.
195
126
  const offline = await measuredManifest('minimal');
196
127
  const hugeRules = Array.from({ length: 200 }, (_, i) => `rule ${i}: ${'x'.repeat(60)}`).join('\n');
197
128
  vi.spyOn(globalThis, 'fetch').mockResolvedValue({