@orboto/mcp 0.221.0 → 0.221.11
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.
- package/dist/event-bridge.js +2 -0
- package/dist/http-transport.js +28 -6
- package/dist/inbox-channel.js +14 -11
- package/dist/instructions-budget.test.js +72 -2
- package/dist/manifest-size.test.js +8 -0
- package/dist/orboto-client.js +3 -1
- package/dist/orboto-client.test.js +1 -1
- package/dist/server.js +18 -28
- package/dist/session-origin.js +100 -0
- package/dist/session-origin.test.js +127 -0
- package/dist/tools/agent-coordination.js +39 -11
- package/dist/tools/agent-coordination.test.js +53 -35
- package/dist/tools/agent-messages.js +16 -5
- package/dist/tools/agent-who.js +23 -8
- package/dist/tools/agent-who.test.js +12 -0
- package/dist/tools/check-similar.js +5 -3
- package/dist/tools/doc-edits.js +6 -4
- package/dist/tools/doc-replace-wrapper.test.js +81 -0
- package/dist/tools/docs-ai.js +4 -0
- package/dist/tools/docs.js +25 -14
- package/dist/tools/docs.test.js +25 -12
- package/dist/tools/embedding-status.js +3 -0
- package/dist/tools/knowledge.js +2 -2
- package/dist/tools/knowledge.test.js +8 -0
- package/dist/tools/list-tickets.js +12 -9
- package/dist/tools/phase-b-tools.test.js +18 -0
- package/dist/tools/responsibilities.js +10 -0
- package/dist/tools/responsibilities.test.js +60 -0
- package/dist/tools/search.js +5 -3
- package/dist/tools/semantic-degraded-wrapper.test.js +70 -0
- package/dist/tools/semantic-degraded.test.js +65 -0
- package/dist/tools/session-start.js +8 -2
- package/dist/tools/shared.js +31 -3
- package/dist/tools/spec-schemas.js +2 -0
- package/dist/tools/wiki.js +15 -9
- package/dist/tools/wiki.test.js +23 -0
- package/dist/toolset.js +3 -2
- package/package.json +1 -1
package/dist/event-bridge.js
CHANGED
|
@@ -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
|
});
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/inbox-channel.js
CHANGED
|
@@ -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 = '
|
|
22
|
-
+ '
|
|
23
|
-
+ '
|
|
24
|
-
+ '
|
|
25
|
-
+ '
|
|
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 {
|
|
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
|
*/
|
package/dist/orboto-client.js
CHANGED
|
@@ -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
|
|
110
|
-
'This is the MINIMAL
|
|
111
|
-
'
|
|
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
|
-
'
|
|
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
|
-
? '
|
|
122
|
-
: '
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
});
|