@orboto/mcp 0.221.3 → 0.222.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 (39) hide show
  1. package/dist/event-bridge.js +2 -0
  2. package/dist/http-transport.js +28 -6
  3. package/dist/inbox-channel.js +14 -11
  4. package/dist/instructions-budget.test.js +72 -2
  5. package/dist/manifest-size.test.js +8 -0
  6. package/dist/orboto-client.js +3 -1
  7. package/dist/orboto-client.test.js +1 -1
  8. package/dist/server.js +18 -28
  9. package/dist/session-origin.js +100 -0
  10. package/dist/session-origin.test.js +127 -0
  11. package/dist/tools/agent-coordination.js +39 -11
  12. package/dist/tools/agent-coordination.test.js +53 -35
  13. package/dist/tools/agent-messages.js +16 -5
  14. package/dist/tools/agent-who.js +23 -8
  15. package/dist/tools/agent-who.test.js +12 -0
  16. package/dist/tools/check-similar.js +5 -3
  17. package/dist/tools/doc-edits.js +6 -4
  18. package/dist/tools/doc-replace-wrapper.test.js +81 -0
  19. package/dist/tools/docs-ai.js +4 -0
  20. package/dist/tools/docs.js +25 -14
  21. package/dist/tools/docs.test.js +25 -12
  22. package/dist/tools/embedding-status.js +3 -0
  23. package/dist/tools/knowledge.js +2 -2
  24. package/dist/tools/knowledge.test.js +8 -0
  25. package/dist/tools/list-tickets.js +12 -9
  26. package/dist/tools/phase-b-tools.test.js +18 -0
  27. package/dist/tools/query.js +1 -1
  28. package/dist/tools/responsibilities.js +10 -0
  29. package/dist/tools/responsibilities.test.js +60 -0
  30. package/dist/tools/search.js +5 -3
  31. package/dist/tools/semantic-degraded-wrapper.test.js +70 -0
  32. package/dist/tools/semantic-degraded.test.js +65 -0
  33. package/dist/tools/session-start.js +8 -2
  34. package/dist/tools/shared.js +31 -3
  35. package/dist/tools/spec-schemas.js +2 -0
  36. package/dist/tools/wiki.js +15 -9
  37. package/dist/tools/wiki.test.js +23 -0
  38. package/dist/toolset.js +3 -2
  39. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { originHeader } from './session-origin.js';
1
2
  const HEARTBEAT_GRACE_MS = 90_000;
2
3
  const OVERFLOW_THRESHOLD = 100;
3
4
  const RECONNECT_DELAY_MS = 2_000;
@@ -119,6 +120,7 @@ export class EventBridge {
119
120
  Authorization: `Bearer ${bearer}`,
120
121
  Accept: 'text/event-stream',
121
122
  ...(this.opts.instanceToken ? { 'x-orboto-agent-session': this.opts.instanceToken } : {}),
123
+ 'x-orboto-origin': originHeader(),
122
124
  },
123
125
  signal: this.abort.signal,
124
126
  });
@@ -11,7 +11,8 @@ import { buildOrbotoMcpServer } from './server.js';
11
11
  import { resolveToolset } from './toolset.js';
12
12
  import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
13
13
  import { EventBridge } from './event-bridge.js';
14
- import { mcpInstanceToken } from './tools/shared.js';
14
+ import { bindSessionInstance, mcpInstanceToken, releaseSessionInstance, stableClientInstance } from './tools/shared.js';
15
+ import { useHttpOrigin } from './session-origin.js';
15
16
  import { releaseCapacityAtSessionEnd } from './tools/capacity-session.js';
16
17
  import { idleSessions, resolveSessionLimits, sessionsOverOwnerCap } from './session-limits.js';
17
18
  /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
@@ -168,6 +169,18 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
168
169
  return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
169
170
  }
170
171
  export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides }) {
172
+ useHttpOrigin();
173
+ /** ORB-2544 - a closed MCP session ends its row resumably unless another live session of the same client still acts under the token. */
174
+ const endInstance = (sessionId, client) => {
175
+ const { token, shared } = releaseSessionInstance(sessionId);
176
+ if (token && !shared)
177
+ void client.post('/v1/agent/end-session', { resumable: true }, { instanceToken: token }).catch(() => { });
178
+ };
179
+ const bindStable = (sessionId, userEmail, credential, client) => {
180
+ const stable = stableClientInstance({ userEmail, credential, client });
181
+ if (stable)
182
+ bindSessionInstance(sessionId, stable);
183
+ };
171
184
  const sessions = new Map();
172
185
  const limits = resolveSessionLimits(undefined, limitOverrides);
173
186
  async function makeRoomFor(userEmail) {
@@ -197,11 +210,12 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
197
210
  return null;
198
211
  }
199
212
  }
200
- async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
213
+ async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset, credential) {
201
214
  await makeRoomFor(userEmail);
215
+ bindStable(chosenSessionId, userEmail, credential, userAgentSuffix);
202
216
  const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset, chosenSessionId);
203
217
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
204
- transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
218
+ transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); endInstance(chosenSessionId, sessionClient); };
205
219
  await mcp.connect(transport);
206
220
  forceInitialized(transport, chosenSessionId);
207
221
  const session = {
@@ -336,10 +350,12 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
336
350
  const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
337
351
  let authValid = false;
338
352
  let callerEmail = '';
353
+ let callerCredential;
339
354
  try {
340
355
  const preflight = await preflightMcpSession(preflightClient);
341
356
  authValid = true;
342
357
  callerEmail = preflight.userEmail;
358
+ callerCredential = preflight.credential;
343
359
  }
344
360
  catch {
345
361
  authValid = false;
@@ -358,13 +374,13 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
358
374
  return sendError(res, 404, 'Unknown or expired MCP session - reinitialize (the server restarted since this session began).');
359
375
  }
360
376
  if (action === 'rehydrate') {
361
- const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset);
377
+ const session = await establishForcedSession({ current: token }, userAgentSuffix, sessionId, callerEmail, toolset, callerCredential);
362
378
  void store.register(token, { sessionId, userAgent }).catch(() => { });
363
379
  await session.transport.handleRequest(req, res, body);
364
380
  return;
365
381
  }
366
382
  const newSessionId = randomUUID();
367
- const session = await establishForcedSession({ current: token }, userAgentSuffix, newSessionId, callerEmail, toolset);
383
+ const session = await establishForcedSession({ current: token }, userAgentSuffix, newSessionId, callerEmail, toolset, callerCredential);
368
384
  overrideSessionIdHeader(req, newSessionId);
369
385
  process.stderr.write(`[orboto-mcp] auto-adopted stale session ${sessionId} → ${newSessionId} (client=${userAgent ?? 'unknown'})\n`);
370
386
  void store
@@ -376,8 +392,11 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
376
392
  if (!sessionId && isInitializeRequest(body)) {
377
393
  const preflightClient = new OrbotoClient({ baseUrl, apiKey: token, userAgentSuffix });
378
394
  let ownerEmail;
395
+ let ownerCredential;
379
396
  try {
380
- ownerEmail = (await preflightMcpSession(preflightClient)).userEmail;
397
+ const preflight = await preflightMcpSession(preflightClient);
398
+ ownerEmail = preflight.userEmail;
399
+ ownerCredential = preflight.credential;
381
400
  }
382
401
  catch (err) {
383
402
  return sendError(res, 401, err.message, {
@@ -394,6 +413,7 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
394
413
  sessions.set(sid, {
395
414
  transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(), lastActiveAt: Date.now(),
396
415
  });
416
+ bindStable(sid, ownerEmail, ownerCredential, userAgentSuffix);
397
417
  bridge.setInstanceToken(mcpInstanceToken(undefined, { sessionId: sid }));
398
418
  bridge.start();
399
419
  void store
@@ -405,6 +425,8 @@ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides
405
425
  if (transport.sessionId)
406
426
  sessions.delete(transport.sessionId);
407
427
  bridge.close();
428
+ if (transport.sessionId)
429
+ endInstance(transport.sessionId, sessionClient);
408
430
  };
409
431
  await mcp.connect(transport);
410
432
  await transport.handleRequest(req, res, body);
@@ -1,10 +1,13 @@
1
1
  import { RESTART_REQUESTED_NOTICE, writeRestartRequest } from './session-restart.js';
2
2
  import { confirmScope, rememberedScope } from './session-scope-memory.js';
3
+ import { originHeader } from './session-origin.js';
3
4
  export { RESTART_REQUESTED_NOTICE };
4
5
  export const CHANNEL_METHOD = 'notifications/claude/channel';
5
6
  /** ORB-2263 - the server notice an unscoped channel session gets; the session sees it once, with the exact CLI call. */
6
7
  export const DECLARE_SCOPE_NOTICE = 'declare_scope';
7
8
  export const CHANNEL_CAPABILITY = 'claude/channel';
9
+ /** ORB-2544 - the operator ended this session: the event says so and the channel stops for good. */
10
+ export const SESSION_ENDED_NOTICE = 'session_ended';
8
11
  export const DEFAULT_DIGEST_MINUTES = 15;
9
12
  const IMMEDIATE_AFTER_MS = 5 * 60_000;
10
13
  const CONTENT_CAP = 2000;
@@ -18,16 +21,11 @@ export function attachmentLine(m) {
18
21
  return m.attachments?.count ? `attachments: ${m.attachments.label} (orboto messages --download ${m.id})` : '';
19
22
  }
20
23
  /** The instructions paragraph Claude Code delivers on connect when the channel is active. */
21
- export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session arrive as <channel source="orboto" id="..." kind="..." ...> events - '
22
- + 'peer requests, ticket-ready notices, replies, digests of info/complete mail. Treat each like an inbox message under the rules: '
23
- + 'a ticket-ready or a request inside your scope is the operator\'s instruction, act on it; read the full message with orboto_messages when the event is cut; '
24
- + 'reply with orboto_agent_notify (toSessionRef = the sender\'s instance short id in the from attribute) and acknowledge with orboto_messages { ackIds } once handled. '
25
- + 'Never answer the channel itself and never ack what you did not handle - dismiss it instead with orboto_messages { dismiss: { ids, reason } }: '
26
- + 'not_mine for another session\'s mail (the sender is told, and the message never reaches this session again), obsolete or duplicate with a note when the request is already done. '
27
- + 'A session that declared no scope is woken by mail addressed to it and by broadcasts only, and gets one notice event saying so on connect: '
28
- + 'declare the scope through the CLI in the shell, `orboto session-start --role worker --scope-projects <KEY>`, to be woken by the account\'s project mail again - never through the claude.ai connector, which is another session - '
29
- + 'the rest of the account\'s inbox stays readable with orboto_messages the whole time. '
30
- + 'Declare it once: the session keeps its id and scope across reconnects and restarts of this terminal.';
24
+ export const CHANNEL_INSTRUCTIONS = 'Wake channel: messages for THIS session arrive as <channel source="orboto" id="..." kind="..."> events. '
25
+ + 'A ticket-ready or a request inside your scope is the operator\'s instruction - act on it. '
26
+ + 'Read a cut message with orboto_messages, reply with orboto_agent_notify, acknowledge with orboto_messages { ackIds } once handled, '
27
+ + 'dismiss what is not yours with orboto_messages { dismiss: { ids, reason } }. Never answer the channel itself. '
28
+ + 'Declare the scope once in the shell with `orboto session-start --role worker --scope-projects <KEY>`, never through the claude.ai connector; details: orboto://rules.';
31
29
  const START_FLAG = '--dangerously-load-development-channels server:orboto';
32
30
  /** ORB-2148 - the commands an agent hands its operator verbatim; docs/claude-code.md carries the same lines. */
33
31
  export const CHANNEL_START_COMMANDS = {
@@ -207,6 +205,11 @@ export class InboxChannel {
207
205
  return;
208
206
  }
209
207
  this.stats.notices += 1;
208
+ if (notice === SESSION_ENDED_NOTICE) {
209
+ await this.emit({ content, meta: { kind: 'notice', notice } });
210
+ this.close();
211
+ return;
212
+ }
210
213
  if (notice === RESTART_REQUESTED_NOTICE) {
211
214
  await this.emit({ content: this.takeRestartRequest(content), meta: { kind: 'notice', notice } });
212
215
  return;
@@ -283,7 +286,7 @@ export class InboxChannel {
283
286
  const bearer = this.opts.tokenProvider ? await this.opts.tokenProvider.getAccessToken() : (this.opts.apiKey ?? '');
284
287
  const res = await fetchFn(url, {
285
288
  method: 'GET',
286
- headers: { Authorization: `Bearer ${bearer}`, Accept: 'text/event-stream', 'x-orboto-agent-session': this.opts.instanceToken },
289
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'text/event-stream', 'x-orboto-agent-session': this.opts.instanceToken, 'x-orboto-origin': originHeader() },
287
290
  signal: this.abort.signal,
288
291
  });
289
292
  if (!res.ok || !res.body)
@@ -3,9 +3,14 @@
3
3
  * client can't silently truncate it mid-rule; the head always survives
4
4
  * and the rules are cut at a whole-line boundary with a pointer to the
5
5
  * cap-independent full set.
6
+ *
7
+ * @see ORB-2552
6
8
  */
7
- import { describe, it, expect } from 'vitest';
8
- import { assembleInstructions } from './server.js';
9
+ import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
12
+ import { assembleInstructions, buildOrbotoMcpServer, CLIENT_INSTRUCTIONS_CAP, MANDATORY_FIRST_ACTION } from './server.js';
13
+ import { CHANNEL_INSTRUCTIONS } from './inbox-channel.js';
9
14
  const HEADING = 'Working rules for this workspace:\n';
10
15
  describe('assembleInstructions (ORB-1177)', () => {
11
16
  it('passes through unchanged when under budget', () => {
@@ -30,3 +35,68 @@ describe('assembleInstructions (ORB-1177)', () => {
30
35
  expect(kept.length).toBeLessThan(lines.length);
31
36
  });
32
37
  });
38
+ const HUGE_RULES = Array.from({ length: 200 }, (_, i) => `rule ${i}: ${'x'.repeat(60)}`).join('\n');
39
+ async function deliveredInstructions(toolset, channel) {
40
+ const body = { instructions: HUGE_RULES, rulesHash: 'orb-2552-fixture' };
41
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
42
+ ok: true,
43
+ status: 200,
44
+ headers: new Headers({ 'content-type': 'application/json' }),
45
+ json: async () => body,
46
+ text: async () => JSON.stringify(body),
47
+ });
48
+ const server = await buildOrbotoMcpServer({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset, channel });
49
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
50
+ const client = new Client({ name: 'instructions-cap', version: '0.0.0' });
51
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
52
+ try {
53
+ return client.getInstructions() ?? '';
54
+ }
55
+ finally {
56
+ await client.close();
57
+ await server.close();
58
+ vi.restoreAllMocks();
59
+ }
60
+ }
61
+ function clientCut(text) {
62
+ return text.length > CLIENT_INSTRUCTIONS_CAP ? text.slice(0, CLIENT_INSTRUCTIONS_CAP) : text;
63
+ }
64
+ describe('ORB-2552 - the mandatory first action survives the client cap', () => {
65
+ beforeEach(() => { delete process.env.ORBOTO_MCP_TOOLSET; });
66
+ afterEach(() => { vi.restoreAllMocks(); });
67
+ it('the cap is the measured Claude Code 2.1.301 value', () => {
68
+ expect(CLIENT_INSTRUCTIONS_CAP).toBe(2048);
69
+ expect(MANDATORY_FIRST_ACTION.startsWith('FIRST ACTION')).toBe(true);
70
+ expect(MANDATORY_FIRST_ACTION).toContain('orboto_session_start');
71
+ expect(MANDATORY_FIRST_ACTION).toContain('orboto://rules');
72
+ });
73
+ for (const toolset of ['minimal', 'curated', 'full']) {
74
+ for (const channel of [false, true]) {
75
+ it(`${toolset}${channel ? ' + wake channel' : ''}: starts with the first action and keeps it whole inside the cap`, async () => {
76
+ const text = await deliveredInstructions(toolset, channel);
77
+ expect(text.startsWith(MANDATORY_FIRST_ACTION)).toBe(true);
78
+ expect(clientCut(text)).toContain(MANDATORY_FIRST_ACTION);
79
+ if (channel)
80
+ expect(clientCut(text)).toContain(CHANNEL_INSTRUCTIONS);
81
+ expect(text.length).toBeLessThanOrEqual(CLIENT_INSTRUCTIONS_CAP);
82
+ if (toolset !== 'full')
83
+ expect(clientCut(text)).toMatch(/orboto_api_search.*orboto_api_call/);
84
+ });
85
+ }
86
+ }
87
+ it('curated and full point at the complete rules inside the cap when the workspace rules overflow it', async () => {
88
+ for (const toolset of ['curated', 'full']) {
89
+ const visible = clientCut(await deliveredInstructions(toolset, false));
90
+ expect(visible).toContain('rules truncated');
91
+ expect(visible).toContain('orboto://rules');
92
+ }
93
+ });
94
+ for (const channel of [false, true]) {
95
+ it(`channel toolset${channel ? ' + wake channel' : ''}: the CLI first action opens the block inside the cap`, async () => {
96
+ const text = await deliveredInstructions('channel', channel);
97
+ const first = /^FIRST ACTION[^]*?compaction\./.exec(text)?.[0] ?? '';
98
+ expect(first).toContain('`orboto session-start`');
99
+ expect(text.length).toBeLessThanOrEqual(CLIENT_INSTRUCTIONS_CAP);
100
+ });
101
+ }
102
+ });
@@ -51,6 +51,14 @@ afterEach(() => { vi.restoreAllMocks(); });
51
51
  * (full = 171,194 chars, 6 free).
52
52
  * ORB-2502 gave `orboto_messages` a `since` parameter inside the ceiling: the
53
53
  * limit lost its schema default and the project hint shrank (full = 171,197).
54
+ * ORB-2542 added no named tool: the owner lookup rides `orboto_who` `for` and
55
+ * the escape hatch; the route/forceTarget parameters fit after tightening the
56
+ * agent_notify description.
57
+ * ORB-2546 gave `orboto_list_tickets` a described `sort` enum inside the
58
+ * ceiling: its parameter descriptions shrank to pay for it (full = 171,197).
59
+ * ORB-2544 added no named tool: ending a session rides the escape hatch;
60
+ * orboto_who `state` and the agent_presence redaction line fit after the
61
+ * agent_presence summary shrank (full = 171,160).
54
62
  *
55
63
  * @see ORB-1805, ORB-1669, ORB-1910
56
64
  */
@@ -3,6 +3,7 @@
3
3
  * the orboto API.
4
4
  */
5
5
  import { VERSION } from './version.js';
6
+ import { originHeader } from './session-origin.js';
6
7
  export class OrbotoApiError extends Error {
7
8
  status;
8
9
  body;
@@ -63,6 +64,7 @@ export class OrbotoClient {
63
64
  const headers = {
64
65
  ...this.baseHeaders,
65
66
  ...(this.instanceToken ? { 'x-orboto-agent-session': this.instanceToken } : {}),
67
+ 'x-orboto-origin': originHeader(),
66
68
  ...(accept ? { Accept: accept } : {}),
67
69
  ...(init.headers ?? {}),
68
70
  Authorization: `Bearer ${token}`,
@@ -219,5 +221,5 @@ export async function preflightMcpSession(client) {
219
221
  if (!status.userMcpEnabled) {
220
222
  throw new Error('MCP preflight failed: you have disabled MCP access for your account. Re-enable it in Profile - Connect an AI Client.');
221
223
  }
222
- return { userEmail: status.userEmail };
224
+ return { userEmail: status.userEmail, credential: status.credential ?? null };
223
225
  }
@@ -99,7 +99,7 @@ describe('preflightMcpSession', () => {
99
99
  it('resolves {userEmail} when enabled + mcpUseGranted + userMcpEnabled', async () => {
100
100
  mockFetch({ json: async () => ({ enabled: true, mcpUseGranted: true, userMcpEnabled: true, userEmail: 'a@b.c' }) });
101
101
  const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
102
- await expect(preflightMcpSession(client)).resolves.toEqual({ userEmail: 'a@b.c' });
102
+ await expect(preflightMcpSession(client)).resolves.toEqual({ userEmail: 'a@b.c', credential: null });
103
103
  });
104
104
  it('throws the per-user opt-out message when userMcpEnabled=false (ORB-942)', async () => {
105
105
  mockFetch({ json: async () => ({ enabled: true, mcpUseGranted: true, userMcpEnabled: false, userEmail: 'a@b.c' }) });
package/dist/server.js CHANGED
@@ -103,32 +103,30 @@ const FALLBACK_WORKING_RULES = [
103
103
  'When you write a git commit that touches a ticket, put the ticket key (e.g. `ORB-42`) in parentheses at the END of the subject line - `feat(auth): add token rotation (ORB-42)`. The orboto git-activity parser links the commit by that key.',
104
104
  'Use sub-tickets for steps large enough to need their own commit / time tracking / review, and checklists for one-liners inside a single ticket\'s scope. Big features (Epic + 3+ phase tickets): create a milestone FIRST, then the Epic, then phase tickets as children on that milestone.',
105
105
  ].join(' ');
106
+ /** ORB-2552 - Claude Code 2.1.301 keeps the first 2048 UTF-16 characters of a server's instructions and appends "… [truncated]" (measured on the claude.ai connector, docs/agent-access-paths.md). */
107
+ export const CLIENT_INSTRUCTIONS_CAP = 2048;
108
+ /** ORB-2552 - the mandatory first action, always the first characters of every tool-carrying instructions block. */
109
+ export const MANDATORY_FIRST_ACTION = 'FIRST ACTION, before anything else: call `orboto_session_start` - it returns the binding workspace rules and your in-progress work; call it again after a context compaction. Non-negotiables: claim or create a ticket before touching code, one commit per ticket with the key in the subject, push after each commit, never mark work done that is not done. Complete rules: `orboto_session_start` or the `orboto://rules` resource.';
106
110
  function staticMcpHints(toolset) {
107
111
  if (toolset === 'minimal') {
108
112
  return [
109
- 'orboto is a ticket + project management system. Ticket keys look like `PROJ-123`; the first segment is the project key.',
110
- 'This is the MINIMAL tool manifest (12 tools) for small context windows. Every other endpoint stays reachable: find it with `orboto_api_search`, run it with `orboto_api_call`. Bigger manifests are opt-in: `?toolset=curated` / `full` (HTTP) or ORBOTO_MCP_TOOLSET (stdio).',
111
- 'Writes respect the caller\'s permissions - a 403 is the API refusing, not this server.',
113
+ 'orboto is a ticket + project management system; ticket keys look like `PROJ-123`.',
114
+ 'This is the MINIMAL manifest (12 tools). Every other endpoint: find it with `orboto_api_search`, run it with `orboto_api_call`. Bigger manifests: `?toolset=curated|full` (HTTP) or ORBOTO_MCP_TOOLSET (stdio).',
115
+ 'A 403 is the API refusing, not this server.',
112
116
  ].join(' ');
113
117
  }
114
118
  return [
115
- 'orboto is a ticket + project management system.',
116
- 'When starting on a project, call `orboto_get_project_primer(<PROJECT_KEY>)` once to load its conventions (tech stack, commands, gotchas, expected ticket language).',
117
- 'Use `orboto_list_projects` first to discover what the user can see.',
118
- 'Ticket keys look like `PROJ-123`; the first segment is the project key.',
119
- 'For "what am I working on?" prefer `orboto_my_tickets`; for "anything about X?" prefer `orboto_search`.',
119
+ 'orboto is a ticket + project management system; ticket keys look like `PROJ-123`.',
120
+ 'Discover projects with `orboto_list_projects`, load one project\'s conventions once with `orboto_get_project_primer(<KEY>)`; "what am I working on?" is `orboto_my_tickets`, "anything about X?" is `orboto_search`.',
120
121
  toolset === 'full'
121
- ? 'Checklists: `orboto_get_ticket` includes them inline; use `orboto_get_checklists` when you only need the items. A linked-ticket suffix (`↪ [ACME-99]`) means the item is automatically checked/unchecked as that ticket\'s status moves.'
122
- : 'Checklists: `orboto_get_ticket` includes them inline. A linked-ticket suffix (`↪ [ACME-99]`) means the item is automatically checked/unchecked as that ticket\'s status moves.',
123
- toolset === 'curated'
124
- ? 'This is the CURATED tool manifest (the daily high-frequency set). The ENTIRE REST API stays reachable: find any other endpoint + its schema with `orboto_api_search`, then execute it with `orboto_api_call` - permissions are enforced server-side exactly as for named tools. The full named-tool manifest is opt-in: connect with `?toolset=full` (HTTP) or set ORBOTO_MCP_TOOLSET=full (stdio).'
125
- : '',
126
- 'Resources (`orboto://rules`, `orboto://ticket/<key>`, `orboto://doc/<id>`, `orboto://project/<key>`, `orboto://search/<query>`) return read-only Markdown. The `orboto://` URI scheme stays canonical. `orboto://rules` returns the COMPLETE binding rules cap-independently (this instructions block may be truncated by the client). Prompts (`plan-sprint`, `triage-my-tickets`, `summarize-project`, `estimate-ticket`, `find-duplicates`) are one-click guided workflows.',
127
- 'A few natural parameter spellings (`key`, `query`, `id`, `projectId`, `milestoneId`, `comment`/`message`, `max`, `page`, ...) are silently normalised to the documented name; anything else that still doesn\'t match returns an error naming the tool\'s full parameter list and the closest valid name.',
128
- 'All writes respect the caller\'s project-level permissions - a 403 means the API rejected the write, not the MCP server.',
129
- ].filter((s) => s.length > 0).join(' ');
122
+ ? '`orboto_get_ticket` includes checklists inline, `orboto_get_checklists` returns only the items.'
123
+ : 'This is the CURATED tool manifest. The ENTIRE REST API stays reachable: find an endpoint with `orboto_api_search`, run it with `orboto_api_call`; the full manifest is `?toolset=full` (HTTP) or ORBOTO_MCP_TOOLSET=full (stdio).',
124
+ 'Read-only resources: `orboto://ticket/<key>`, `orboto://doc/<id>`, `orboto://project/<key>`, `orboto://search/<query>`.',
125
+ '`orboto_list_agent_instructions` manages rule blocks for admins; it is not how you read the rules.',
126
+ 'A 403 is the API refusing the write, not this server.',
127
+ ].join(' ');
130
128
  }
131
- const INSTRUCTIONS_BUDGET = 4000;
129
+ const INSTRUCTIONS_BUDGET = CLIENT_INSTRUCTIONS_CAP;
132
130
  const RULES_HEADING = 'Working rules for this workspace:\n';
133
131
  const TRUNCATION_MARKER = '\n\n[... rules truncated to fit the client cap - read the COMPLETE rules via the orboto_session_start tool or the orboto://rules resource ...]';
134
132
  export function assembleInstructions(head, workingRules, budget = INSTRUCTIONS_BUDGET) {
@@ -174,16 +172,8 @@ export async function buildOrbotoMcpServer(opts) {
174
172
  instructions: toolset === 'channel'
175
173
  ? [CHANNEL_TOOLSET_INSTRUCTIONS, ...(opts.channel ? [CHANNEL_TOOLSET_WAKE_INSTRUCTIONS] : [])].join('\n\n')
176
174
  : toolset === 'minimal'
177
- ? [
178
- staticMcpHints(toolset),
179
- 'FIRST ACTION: call `orboto_session_start` - it returns the binding workspace rules plus your in-progress work. Re-run it after any context compaction. Non-negotiables: claim or create a ticket before touching code, one commit per ticket with the ticket key in the subject, push after each commit, never mark work done that is not done.',
180
- ...channelHint,
181
- ].join('\n\n')
182
- : assembleInstructions([
183
- staticMcpHints(toolset),
184
- 'FIRST ACTION this session: call the `orboto_session_start` tool - it returns the complete, authoritative binding rules you must follow (plus your in-progress work). Re-run it after any context compaction. If the rules below look cut off, `orboto_session_start` and the `orboto://rules` resource always have the full set. (Do NOT use `orboto_list_agent_instructions` to read the rules - that manages rule blocks for admins.) Core non-negotiables: ticket-first (claim or create a ticket before touching code), one commit per ticket with the ticket key in the subject line, push after each commit, and never mark work done that is not actually done.',
185
- ...channelHint,
186
- ].join('\n\n'), workingRules),
175
+ ? [MANDATORY_FIRST_ACTION, ...channelHint, staticMcpHints(toolset)].join('\n\n')
176
+ : assembleInstructions([MANDATORY_FIRST_ACTION, ...channelHint, staticMcpHints(toolset)].join('\n\n'), workingRules),
187
177
  });
188
178
  const nudgeState = createNudgeState(requireSessionStart);
189
179
  const regAll = registerWithMetrics(server, client, opts.userAgentSuffix, nudgeState);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ORB-2544 - the `x-orboto-origin` header every request of this MCP server
3
+ * carries: a stdio proxy names the harness that started it (or the origin the
4
+ * CLI handed over in ORBOTO_ORIGIN), the HTTP server names itself as an
5
+ * mcp-oauth process; a repository is only ever its basename.
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { hostname } from 'node:os';
11
+ import { basename, dirname, join } from 'node:path';
12
+ import { VERSION } from './version.js';
13
+ const KEYS = [
14
+ ['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'],
15
+ ];
16
+ /** The header value; empty fields are left out. */
17
+ export function formatOrigin(origin) {
18
+ const params = new URLSearchParams();
19
+ for (const [key, field] of KEYS) {
20
+ const value = origin[field];
21
+ if (value !== null && value !== undefined && value !== '')
22
+ params.set(key, String(value));
23
+ }
24
+ return params.toString();
25
+ }
26
+ /** The first label of the host name, lower-cased. */
27
+ export function shortHost(raw = hostname()) {
28
+ const first = raw.trim().toLowerCase().replace(/\.+$/, '').split('.')[0]?.replace(/[^a-z0-9_-]/g, '') ?? '';
29
+ return first || null;
30
+ }
31
+ /** The repository the working directory sits in, as a basename; the directory's own basename outside a repository. */
32
+ export function repoBasename(cwd = process.cwd()) {
33
+ let dir = cwd;
34
+ for (let i = 0; i < 40; i += 1) {
35
+ if (existsSync(join(dir, '.git')))
36
+ return basename(dir) || null;
37
+ const parent = dirname(dir);
38
+ if (parent === dir)
39
+ break;
40
+ dir = parent;
41
+ }
42
+ return basename(cwd) || null;
43
+ }
44
+ let machine;
45
+ /** A hashed stable machine id (the platform UUID on macOS, /etc/machine-id elsewhere); null when none is readable. */
46
+ export function machineId(read = readMachineId) {
47
+ if (machine !== undefined)
48
+ return machine;
49
+ const raw = read();
50
+ machine = raw ? createHash('sha256').update(raw.trim()).digest('hex').slice(0, 16) : null;
51
+ return machine;
52
+ }
53
+ function readMachineId() {
54
+ try {
55
+ if (process.platform === 'darwin') {
56
+ const out = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 2000 });
57
+ return /"IOPlatformUUID" = "([^"]+)"/.exec(out)?.[1] ?? null;
58
+ }
59
+ for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id'])
60
+ if (existsSync(path))
61
+ return readFileSync(path, 'utf8').trim() || null;
62
+ }
63
+ catch { /* an unreadable id leaves the origin without one */ }
64
+ return null;
65
+ }
66
+ /** The stdio proxy's origin: what the CLI handed over, else the harness that started this process. */
67
+ export function stdioOrigin(env = process.env) {
68
+ const handed = (env.ORBOTO_ORIGIN ?? '').trim();
69
+ if (handed) {
70
+ const params = new URLSearchParams(handed);
71
+ params.set('client', 'orboto-mcp');
72
+ params.set('version', VERSION);
73
+ return params.toString();
74
+ }
75
+ return formatOrigin({
76
+ host: shortHost(), machineId: machineId(), repo: repoBasename(), pid: process.ppid || null,
77
+ client: 'orboto-mcp', clientVersion: VERSION,
78
+ harness: env.CLAUDECODE ? 'claude-code' : env.CODEX_THREAD_ID || env.CODEX_SESSION_ID ? 'codex' : 'mcp-stdio',
79
+ });
80
+ }
81
+ /** The HTTP server's origin: the server process itself, the identity of every connection is its OAuth client. */
82
+ export function httpOrigin() {
83
+ return formatOrigin({
84
+ pid: process.pid, startedAt: new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString(),
85
+ client: 'orboto-mcp', clientVersion: VERSION, harness: 'mcp-oauth',
86
+ });
87
+ }
88
+ let mode = 'stdio';
89
+ let cached = null;
90
+ /** The HTTP transport switches the process to its own origin on start. */
91
+ export function useHttpOrigin() {
92
+ mode = 'http';
93
+ cached = null;
94
+ }
95
+ /** The header value every request of this process sends. */
96
+ export function originHeader() {
97
+ if (!cached || cached.mode !== mode)
98
+ cached = { mode, value: mode === 'http' ? httpOrigin() : stdioOrigin() };
99
+ return cached.value;
100
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ORB-2544 - the origin header and one session row per MCP client.
3
+ */
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createServer as createNodeServer } from 'node:http';
6
+ import { mkdtempSync, mkdirSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { createHttpServer } from './http-transport.js';
10
+ import { formatOrigin, machineId, repoBasename, shortHost, stdioOrigin } from './session-origin.js';
11
+ import { bindSessionInstance, mcpInstanceToken, releaseSessionInstance, stableClientInstance } from './tools/shared.js';
12
+ import { InboxChannel, SESSION_ENDED_NOTICE } from './inbox-channel.js';
13
+ describe('ORB-2544 - the origin header', () => {
14
+ it('formats the fields it has and leaves the rest out', () => {
15
+ expect(formatOrigin({ host: 'build-host', pid: 12, harness: 'claude-code', repo: null })).toBe('host=build-host&pid=12&harness=claude-code');
16
+ });
17
+ it('a stdio proxy takes the origin the CLI handed over and names itself as the client', () => {
18
+ const params = new URLSearchParams(stdioOrigin({ ORBOTO_ORIGIN: 'host=build-host&pid=99&harness=claude-code&client=orboto-cli&version=1.0.0' }));
19
+ expect(params.get('pid')).toBe('99');
20
+ expect(params.get('harness')).toBe('claude-code');
21
+ expect(params.get('client')).toBe('orboto-mcp');
22
+ });
23
+ it('reduces the host to its first label and a checkout to the repository basename', () => {
24
+ expect(shortHost('Build-Host.example.lan.')).toBe('build-host');
25
+ const root = mkdtempSync(join(tmpdir(), 'origin-'));
26
+ mkdirSync(join(root, 'sample-repo', '.git'), { recursive: true });
27
+ mkdirSync(join(root, 'sample-repo', 'apps', 'api'), { recursive: true });
28
+ expect(repoBasename(join(root, 'sample-repo', 'apps', 'api'))).toBe('sample-repo');
29
+ });
30
+ it('hashes the machine id once', () => {
31
+ const read = vi.fn(() => 'platform-uuid');
32
+ const first = machineId(read);
33
+ expect(first === null || /^[0-9a-f]{16}$/.test(first)).toBe(true);
34
+ expect(machineId(read)).toBe(first);
35
+ });
36
+ });
37
+ describe('ORB-2544 - one token per MCP client', () => {
38
+ it('the same account, credential and client keep one token; another client id gets its own; no id falls back', () => {
39
+ const one = stableClientInstance({ userEmail: 'a@example.com', credential: { kind: 'oauth', id: 'client-1' }, client: 'claude-code@2.0' });
40
+ expect(one).toMatch(/^mcp-[0-9a-f]{16}$/);
41
+ expect(stableClientInstance({ userEmail: 'A@example.com', credential: { kind: 'oauth', id: 'client-1' }, client: 'claude-code@2.1' })).toBe(one);
42
+ expect(stableClientInstance({ userEmail: 'a@example.com', credential: { kind: 'oauth', id: 'client-2' }, client: 'claude-code' })).not.toBe(one);
43
+ expect(stableClientInstance({ userEmail: 'a@example.com', credential: null })).toBeNull();
44
+ });
45
+ it('a bound transport session acts under the stable token until it is released', () => {
46
+ bindSessionInstance('sid-1', 'mcp-stable');
47
+ bindSessionInstance('sid-2', 'mcp-stable');
48
+ expect(mcpInstanceToken(undefined, { sessionId: 'sid-1' })).toBe('mcp-stable');
49
+ expect(releaseSessionInstance('sid-1')).toEqual({ token: 'mcp-stable', shared: true });
50
+ expect(releaseSessionInstance('sid-2')).toEqual({ token: 'mcp-stable', shared: false });
51
+ expect(mcpInstanceToken(undefined, { sessionId: 'sid-1' })).toBe('mcp-sid-1');
52
+ });
53
+ });
54
+ let server = null;
55
+ let api = null;
56
+ afterEach(async () => {
57
+ if (server) {
58
+ const internals = server.__mcp;
59
+ for (const s of internals?.sessions.values() ?? []) {
60
+ try {
61
+ await s.transport.close();
62
+ }
63
+ catch { /* ignore */ }
64
+ }
65
+ server.close();
66
+ server = null;
67
+ }
68
+ await new Promise((r) => (api ? api.close(() => r()) : r()));
69
+ api = null;
70
+ });
71
+ describe('ORB-2544 - an MCP client reconnecting five times', () => {
72
+ it('opens every live-events stream under one token and ends it resumably on each close', async () => {
73
+ const streams = [];
74
+ const ended = [];
75
+ const origins = [];
76
+ api = createNodeServer((req, res) => {
77
+ const url = req.url ?? '';
78
+ if (url.startsWith('/system/mcp/status')) {
79
+ res.writeHead(200, { 'content-type': 'application/json' });
80
+ res.end(JSON.stringify({ enabled: true, mcpUseGranted: true, userMcpEnabled: true, userEmail: 'alice@example.com', credential: { kind: 'oauth', id: 'client-1' } }));
81
+ return;
82
+ }
83
+ if (url.startsWith('/sse/mcp-events')) {
84
+ streams.push(String(req.headers['x-orboto-agent-session'] ?? ''));
85
+ origins.push(String(req.headers['x-orboto-origin'] ?? ''));
86
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
87
+ res.end();
88
+ return;
89
+ }
90
+ if (url.startsWith('/v1/agent/end-session')) {
91
+ ended.push(String(req.headers['x-orboto-agent-session'] ?? ''));
92
+ res.writeHead(200, { 'content-type': 'application/json' });
93
+ res.end('{}');
94
+ return;
95
+ }
96
+ res.writeHead(200, { 'content-type': 'application/json' });
97
+ res.end(url.startsWith('/agent-instructions') ? JSON.stringify({ instructions: '' }) : '{}');
98
+ });
99
+ await new Promise((r) => api.listen(0, r));
100
+ const store = { register: async () => { }, resolve: async () => false, remove: async () => { } };
101
+ server = createHttpServer({ baseUrl: `http://127.0.0.1:${api.address().port}`, sessionStore: store });
102
+ await new Promise((r) => server.listen(0, r));
103
+ const base = `http://127.0.0.1:${server.address().port}`;
104
+ const init = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } } });
105
+ for (let i = 0; i < 5; i += 1) {
106
+ const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: 'Bearer orb_alice', 'user-agent': 'claude-code/2.0' }, body: init });
107
+ const sid = res.headers.get('mcp-session-id');
108
+ await res.text();
109
+ await vi.waitFor(() => expect(streams.length).toBe(i + 1));
110
+ await fetch(`${base}/mcp`, { method: 'DELETE', headers: { authorization: 'Bearer orb_alice', 'mcp-session-id': sid } });
111
+ await vi.waitFor(() => expect(ended.length).toBe(i + 1));
112
+ }
113
+ const expected = stableClientInstance({ userEmail: 'alice@example.com', credential: { kind: 'oauth', id: 'client-1' }, client: 'claude-code' });
114
+ expect(new Set(streams)).toEqual(new Set([expected]));
115
+ expect(new Set(ended)).toEqual(new Set([expected]));
116
+ expect(new URLSearchParams(origins[0]).get('harness')).toBe('mcp-oauth');
117
+ });
118
+ });
119
+ describe('ORB-2544 - the end notice on the wake channel', () => {
120
+ it('emits the notice once and stops the channel', async () => {
121
+ const notification = vi.fn(async () => { });
122
+ const channel = new InboxChannel({ baseUrl: 'http://127.0.0.1:1', apiKey: 'k', instanceToken: 'tok', mcp: { server: { notification } }, fetchFn: (() => new Promise(() => { })) });
123
+ await channel.deliverNotice(SESSION_ENDED_NOTICE, 'The operator ended this orboto session (stale).');
124
+ expect(notification).toHaveBeenCalledTimes(1);
125
+ expect(channel.closed).toBe(true);
126
+ });
127
+ });