@orboto/mcp 0.212.4 → 0.212.5

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.
@@ -69,11 +69,11 @@ const store = {
69
69
  resolve: async () => false,
70
70
  remove: async () => { },
71
71
  };
72
- async function start() {
72
+ async function start(limits) {
73
73
  api = fakeApiPerUser();
74
74
  await new Promise((r) => api.listen(0, r));
75
75
  const { port: apiPort } = api.address();
76
- server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
76
+ server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store, limits });
77
77
  await new Promise((r) => server.listen(0, r));
78
78
  const { port } = server.address();
79
79
  return `http://127.0.0.1:${port}`;
@@ -145,4 +145,31 @@ describe('ORB-1576 - request body cap', () => {
145
145
  expect(errored || status === 400).toBe(true);
146
146
  });
147
147
  });
148
- void vi;
148
+ function internals() {
149
+ return server.__mcp;
150
+ }
151
+ describe('per-user session cap and idle reap', () => {
152
+ it('closes the owner\'s least recently used session when a new one would exceed the cap', async () => {
153
+ const base = await start({ maxSessionsPerUser: 2 });
154
+ const first = await initSession(base, 'orb_alice');
155
+ const second = await initSession(base, 'orb_alice');
156
+ const mallory = await initSession(base, 'orb_mallory');
157
+ const third = await initSession(base, 'orb_alice');
158
+ const ids = [...internals().sessions.keys()];
159
+ expect(ids).not.toContain(first);
160
+ expect(ids).toEqual(expect.arrayContaining([second, third, mallory]));
161
+ expect([...internals().sessions.values()].filter((s) => s.userEmail === 'alice@example.com')).toHaveLength(2);
162
+ });
163
+ it('drops sessions that stayed idle past the limit', async () => {
164
+ const base = await start({ idleMs: 1_000, reapIntervalMs: 20 });
165
+ const sid = await initSession(base, 'orb_alice');
166
+ internals().sessions.get(sid).lastActiveAt = Date.now() - 5_000;
167
+ await vi.waitFor(() => expect(internals().sessions.has(sid)).toBe(false), { timeout: 2_000 });
168
+ });
169
+ it('keeps an active session past the idle limit check', async () => {
170
+ const base = await start({ idleMs: 60_000, reapIntervalMs: 20 });
171
+ const sid = await initSession(base, 'orb_alice');
172
+ await new Promise((r) => setTimeout(r, 100));
173
+ expect(internals().sessions.has(sid)).toBe(true);
174
+ });
175
+ });
@@ -13,6 +13,7 @@ import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
13
13
  import { EventBridge } from './event-bridge.js';
14
14
  import { mcpInstanceToken } from './tools/shared.js';
15
15
  import { releaseCapacityAtSessionEnd } from './tools/capacity-session.js';
16
+ import { idleSessions, resolveSessionLimits, sessionsOverOwnerCap } from './session-limits.js';
16
17
  /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
17
18
  * call builds a short-lived OrbotoClient bound to the request's token so the
18
19
  * api scopes every read/write to that identity. */
@@ -166,8 +167,14 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
166
167
  const resourceMetadata = `${origin}/.well-known/oauth-protected-resource`;
167
168
  return `Bearer realm="orboto-mcp", error="${error}", error_description="${description.replace(/"/g, '\\"')}", resource_metadata="${resourceMetadata}"`;
168
169
  }
169
- export function createHttpServer({ baseUrl, sessionStore }) {
170
+ export function createHttpServer({ baseUrl, sessionStore, limits: limitOverrides }) {
170
171
  const sessions = new Map();
172
+ const limits = resolveSessionLimits(undefined, limitOverrides);
173
+ async function makeRoomFor(userEmail) {
174
+ const over = sessionsOverOwnerCap(sessions.values(), userEmail, limits.maxSessionsPerUser);
175
+ if (over.length > 0)
176
+ await closeAllMcpSessions(over, `you reached the limit of ${limits.maxSessionsPerUser} open sessions; the least recently used one is closed.`);
177
+ }
171
178
  const store = sessionStore ?? createApiSessionStore(baseUrl);
172
179
  async function buildSessionCore(tokenHolder, userAgentSuffix, toolset, sessionId) {
173
180
  const tokenProvider = holderTokenProvider(tokenHolder);
@@ -191,13 +198,14 @@ export function createHttpServer({ baseUrl, sessionStore }) {
191
198
  }
192
199
  }
193
200
  async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
201
+ await makeRoomFor(userEmail);
194
202
  const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset, chosenSessionId);
195
203
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
196
204
  transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
197
205
  await mcp.connect(transport);
198
206
  forceInitialized(transport, chosenSessionId);
199
207
  const session = {
200
- transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail, lastTouchAt: Date.now(),
208
+ transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail, lastTouchAt: Date.now(), lastActiveAt: Date.now(),
201
209
  };
202
210
  sessions.set(chosenSessionId, session);
203
211
  bridge.start();
@@ -228,6 +236,12 @@ export function createHttpServer({ baseUrl, sessionStore }) {
228
236
  }
229
237
  const killSwitchTimer = setInterval(() => { void pollKillSwitch(); }, pollMs);
230
238
  killSwitchTimer.unref?.();
239
+ const reapTimer = setInterval(() => {
240
+ const idle = idleSessions(sessions.values(), Date.now(), limits.idleMs);
241
+ if (idle.length > 0)
242
+ void closeAllMcpSessions(idle, 'the session was idle too long.');
243
+ }, limits.reapIntervalMs);
244
+ reapTimer.unref?.();
231
245
  const server = createServer(async (req, res) => {
232
246
  if (req.method === 'GET' && req.url === '/health') {
233
247
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -309,6 +323,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
309
323
  process.stderr.write(`[orboto-mcp] session ${sessionId} bearer rotated - adopting client's current access token\n`);
310
324
  session.tokenHolder.current = token;
311
325
  }
326
+ session.lastActiveAt = Date.now();
312
327
  await session.transport.handleRequest(req, res, body);
313
328
  const now = Date.now();
314
329
  if (now - session.lastTouchAt >= TOUCH_THROTTLE_MS) {
@@ -369,6 +384,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
369
384
  'WWW-Authenticate': wwwAuthChallenge(req, baseUrl, 'invalid_token', err.message),
370
385
  });
371
386
  }
387
+ await makeRoomFor(ownerEmail);
372
388
  const tokenHolder = { current: token };
373
389
  const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
374
390
  const clientInfo = clientInfoLabel(body);
@@ -376,7 +392,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
376
392
  sessionIdGenerator: () => randomUUID(),
377
393
  onsessioninitialized: (sid) => {
378
394
  sessions.set(sid, {
379
- transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
395
+ transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(), lastActiveAt: Date.now(),
380
396
  });
381
397
  bridge.setInstanceToken(mcpInstanceToken(undefined, { sessionId: sid }));
382
398
  bridge.start();
@@ -396,7 +412,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
396
412
  }
397
413
  sendError(res, 400, 'Missing mcp-session-id header or initialize request');
398
414
  });
399
- server.on('close', () => clearInterval(killSwitchTimer));
415
+ server.on('close', () => { clearInterval(killSwitchTimer); clearInterval(reapTimer); });
400
416
  server.__mcp = { sessions, store };
401
417
  return server;
402
418
  }
@@ -79,6 +79,7 @@ describe('ORB-941 - graceful close of in-flight MCP sessions on kill-switch', ()
79
79
  tokenHolder: { current: 'orb_dummy' },
80
80
  userEmail: 'owner@orboto.test',
81
81
  lastTouchAt: Date.now(),
82
+ lastActiveAt: Date.now(),
82
83
  };
83
84
  return { session, close, log };
84
85
  }
@@ -262,12 +262,27 @@ export function createTokenProvider(initial, refresh, onRefreshed, now = Date.no
262
262
  }
263
263
  /** Open a URL in the user's default browser. Returns false if no opener is
264
264
  * available (headless) so the caller can print the URL for manual paste. */
265
+ /** The opener command for a platform; never a shell, so the URL is one argv entry (Windows uses the URL protocol handler like the Go CLI). */
266
+ function browserOpenerCommand(platform, url) {
267
+ if (platform === 'darwin')
268
+ return { cmd: 'open', args: [url] };
269
+ if (platform === 'win32')
270
+ return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
271
+ return { cmd: 'xdg-open', args: [url] };
272
+ }
265
273
  export async function openInBrowser(url) {
266
274
  if (process.env.ORBOTO_MCP_NO_BROWSER === '1')
267
275
  return false;
268
- const platform = process.platform;
269
- const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
270
- const args = platform === 'win32' ? ['/c', 'start', '""', url] : [url];
276
+ let parsed;
277
+ try {
278
+ parsed = new URL(url);
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
284
+ return false;
285
+ const { cmd, args } = browserOpenerCommand(process.platform, parsed.href);
271
286
  try {
272
287
  const { spawn } = await import('node:child_process');
273
288
  const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
@@ -0,0 +1,37 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ const spawn = vi.fn(() => ({ unref: () => { } }));
3
+ vi.mock('node:child_process', () => ({ spawn }));
4
+ const { openInBrowser } = await import('./oauth-bootstrap.js');
5
+ const realPlatform = process.platform;
6
+ function setPlatform(p) {
7
+ Object.defineProperty(process, 'platform', { value: p, configurable: true });
8
+ }
9
+ afterEach(() => {
10
+ setPlatform(realPlatform);
11
+ spawn.mockClear();
12
+ delete process.env.ORBOTO_MCP_NO_BROWSER;
13
+ });
14
+ const URL_WITH_METACHARS = 'https://auth.example.test/authorize&calc.exe|x^y?client_id=a&state=b';
15
+ describe('OAuth browser opener', () => {
16
+ it('opens the URL on Windows without a command shell, as a single argument', async () => {
17
+ setPlatform('win32');
18
+ expect(await openInBrowser(URL_WITH_METACHARS)).toBe(true);
19
+ expect(spawn).toHaveBeenCalledTimes(1);
20
+ const [cmd, args] = spawn.mock.calls[0];
21
+ expect(cmd).not.toMatch(/cmd|powershell/i);
22
+ expect(args.filter((a) => a.includes('auth.example.test'))).toHaveLength(1);
23
+ expect(args).not.toContain('/c');
24
+ });
25
+ it('opens the URL directly on macOS and Linux', async () => {
26
+ setPlatform('linux');
27
+ await openInBrowser('https://auth.example.test/authorize?x=1');
28
+ expect(spawn.mock.calls[0]).toEqual(['xdg-open', ['https://auth.example.test/authorize?x=1'], expect.anything()]);
29
+ });
30
+ it('refuses anything but an http or https URL', async () => {
31
+ setPlatform('win32');
32
+ for (const url of ['file:///C:/Windows/System32/calc.exe', 'javascript:alert(1)', 'not a url']) {
33
+ expect(await openInBrowser(url)).toBe(false);
34
+ }
35
+ expect(spawn).not.toHaveBeenCalled();
36
+ });
37
+ });
@@ -127,15 +127,19 @@ function pruneHandles(now) {
127
127
  handles.delete(oldest.value);
128
128
  }
129
129
  }
130
- export function storePayload(toolName, payload, now = Date.now()) {
131
- const handle = randomUUID().slice(0, 8);
132
- handles.set(handle, { toolName, storedAt: now, ...payload });
130
+ /** Owner used by single-session callers (stdio, tests); HTTP sessions pass their own client. */
131
+ export const PROCESS_OWNER = Object.freeze({});
132
+ export function storePayload(toolName, payload, now = Date.now(), owner = PROCESS_OWNER) {
133
+ const handle = randomUUID().replace(/-/g, '').slice(0, 16);
134
+ handles.set(handle, { toolName, storedAt: now, owner, ...payload });
133
135
  pruneHandles(now);
134
136
  return handle;
135
137
  }
136
- export function readPayload(handle, now = Date.now()) {
138
+ /** The stored payload for a handle, or null when it expired or belongs to another session. */
139
+ export function readPayload(handle, now = Date.now(), owner = PROCESS_OWNER) {
137
140
  pruneHandles(now);
138
- return handles.get(handle) ?? null;
141
+ const stored = handles.get(handle);
142
+ return stored && stored.owner === owner ? stored : null;
139
143
  }
140
144
  /** Record what the shrink pass cut, so the expand tool can list the paths. */
141
145
  export function annotatePayload(handle, omitted) {
@@ -245,7 +249,7 @@ function stringMarker(omitted, handle, path) {
245
249
  * `/admin/mcp/instrument`, so budget pressure stays visible in the
246
250
  * admin MCP-usage panel instead of needing another transcript audit.
247
251
  */
248
- export function applyResponseBudget(toolName, result, env = process.env) {
252
+ export function applyResponseBudget(toolName, result, env = process.env, owner = PROCESS_OWNER) {
249
253
  const protectText = takeProtectTextFlag(result);
250
254
  const originalChars = measureResult(result);
251
255
  if (!budgetEnabled(env)) {
@@ -256,20 +260,20 @@ export function applyResponseBudget(toolName, result, env = process.env) {
256
260
  return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
257
261
  }
258
262
  try {
259
- return shrink(toolName, result, budget, originalChars, protectText);
263
+ return shrink(toolName, result, budget, originalChars, protectText, owner);
260
264
  }
261
265
  catch {
262
266
  return { result, responseChars: originalChars, originalChars, truncatedChars: 0 };
263
267
  }
264
268
  }
265
- function shrink(toolName, result, budget, originalChars, protectText = false) {
269
+ function shrink(toolName, result, budget, originalChars, protectText = false, owner = PROCESS_OWNER) {
266
270
  const fullText = (result.content ?? [])
267
271
  .map((part) => textOf(part) ?? '')
268
272
  .join('\n');
269
273
  const handle = storePayload(toolName, {
270
274
  structuredContent: result.structuredContent,
271
275
  text: fullText,
272
- });
276
+ }, Date.now(), owner);
273
277
  const omitted = [];
274
278
  const structured = result.structuredContent === undefined
275
279
  ? undefined
@@ -237,6 +237,23 @@ describe('nothing is lost - orboto_response_expand', () => {
237
237
  expect(resolvePath(root, '')).toBe(root);
238
238
  });
239
239
  });
240
+ describe('a handle is readable only by the session that produced it', () => {
241
+ it('another session cannot read or expand a handle, the owner can', async () => {
242
+ const sessionA = {};
243
+ const sessionB = {};
244
+ const out = applyResponseBudget(TOOL, result({ description: 'private '.repeat(5_000) }), {}, sessionA);
245
+ expect(out.handle).toBeDefined();
246
+ expect(readPayload(out.handle, Date.now(), sessionB)).toBeNull();
247
+ const foreign = await makeResponseExpandHandler(sessionB)({ handle: out.handle, path: 'description' });
248
+ expect(foreign.isError).toBe(true);
249
+ expect(JSON.stringify(foreign)).not.toContain('private');
250
+ const own = await makeResponseExpandHandler(sessionA)({ handle: out.handle, path: 'description' });
251
+ expect(own.isError).toBeUndefined();
252
+ });
253
+ it('issues handles with at least 64 bits of randomness', () => {
254
+ expect(storePayload(TOOL, { text: 'a' })).toMatch(/^[0-9a-f]{16}$/);
255
+ });
256
+ });
240
257
  describe('the handle store is bounded', () => {
241
258
  it('evicts the oldest beyond MAX_HANDLES', () => {
242
259
  const first = storePayload(TOOL, { text: 'a' });
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { registerOrbotoPrompts } from './prompts.js';
10
10
  import { registerWithMetrics } from './with-metrics.js';
11
11
  import { CHANNEL_TOOLSET_INSTRUCTIONS, CHANNEL_TOOLSET_WAKE_INSTRUCTIONS, resolveToolset, toolInToolset } from './toolset.js';
12
12
  import { createNudgeState } from './session-nudge.js';
13
+ import { assertSubscribable, uriForLog } from './session-limits.js';
13
14
  import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
14
15
  import { draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler } from './tools/customer-draft.js';
15
16
  import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
@@ -200,7 +201,7 @@ export async function buildOrbotoMcpServer(opts) {
200
201
  reg('orboto_ai_usage', aiUsageToolConfig, makeAiUsageHandler(client));
201
202
  reg('orboto_secret_scan', secretScanToolConfig, makeSecretScanHandler(client));
202
203
  reg('orboto_session_start', sessionStartToolConfig, makeSessionStartHandler(client, { channel: opts.channel }));
203
- reg('orboto_response_expand', responseExpandToolConfig, makeResponseExpandHandler());
204
+ reg('orboto_response_expand', responseExpandToolConfig, makeResponseExpandHandler(client));
204
205
  reg('orboto_help', helpToolConfig, makeHelpHandler());
205
206
  reg('orboto_api_search', apiSearchToolConfig, makeApiSearchHandler(client));
206
207
  reg('orboto_api_call', apiCallToolConfig, makeApiCallHandler(client));
@@ -392,9 +393,10 @@ export async function buildOrbotoMcpServer(opts) {
392
393
  if (opts.subscriptions) {
393
394
  const subs = opts.subscriptions;
394
395
  server.server.setRequestHandler(SubscribeRequestSchema, async (req) => {
396
+ assertSubscribable(subs, req.params.uri);
395
397
  subs.add(req.params.uri);
396
398
  try {
397
- process.stderr.write(`[orboto-mcp] subscribe → ${req.params.uri} (total subs: ${subs.size})\n`);
399
+ process.stderr.write(`[orboto-mcp] subscribe → ${uriForLog(req.params.uri)} (total subs: ${subs.size})\n`);
398
400
  }
399
401
  catch { /* ignore */ }
400
402
  return {};
@@ -402,7 +404,7 @@ export async function buildOrbotoMcpServer(opts) {
402
404
  server.server.setRequestHandler(UnsubscribeRequestSchema, async (req) => {
403
405
  subs.delete(req.params.uri);
404
406
  try {
405
- process.stderr.write(`[orboto-mcp] unsubscribe → ${req.params.uri} (remaining: ${subs.size})\n`);
407
+ process.stderr.write(`[orboto-mcp] unsubscribe → ${uriForLog(req.params.uri)} (remaining: ${subs.size})\n`);
406
408
  }
407
409
  catch { /* ignore */ }
408
410
  return {};
@@ -0,0 +1,39 @@
1
+ /** Bounds on in-memory MCP HTTP sessions and their resource subscriptions. */
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ function positiveInt(raw, fallback) {
4
+ const n = Number(raw);
5
+ return Number.isInteger(n) && n > 0 ? n : fallback;
6
+ }
7
+ export function resolveSessionLimits(env = {
8
+ ORBOTO_MCP_MAX_SESSIONS_PER_USER: process.env.ORBOTO_MCP_MAX_SESSIONS_PER_USER,
9
+ ORBOTO_MCP_SESSION_IDLE_MS: process.env.ORBOTO_MCP_SESSION_IDLE_MS,
10
+ }, override = {}) {
11
+ const idleMs = override.idleMs ?? positiveInt(env.ORBOTO_MCP_SESSION_IDLE_MS, 6 * 60 * 60_000);
12
+ return {
13
+ maxSessionsPerUser: override.maxSessionsPerUser ?? positiveInt(env.ORBOTO_MCP_MAX_SESSIONS_PER_USER, 32),
14
+ idleMs,
15
+ reapIntervalMs: override.reapIntervalMs ?? Math.min(idleMs, 60_000),
16
+ };
17
+ }
18
+ /** The owner's least recently active sessions that must close so one more fits under the cap. */
19
+ export function sessionsOverOwnerCap(sessions, userEmail, max) {
20
+ const owned = [...sessions].filter((s) => s.userEmail === userEmail).sort((a, b) => a.lastActiveAt - b.lastActiveAt);
21
+ return owned.slice(0, Math.max(0, owned.length - max + 1));
22
+ }
23
+ export function idleSessions(sessions, now, idleMs) {
24
+ return [...sessions].filter((s) => now - s.lastActiveAt >= idleMs);
25
+ }
26
+ export const MAX_SUBSCRIPTIONS_PER_SESSION = 500;
27
+ export const MAX_SUBSCRIPTION_URI_LENGTH = 2048;
28
+ /** Validates a resources/subscribe URI before it joins the session's set; throws InvalidParams otherwise. */
29
+ export function assertSubscribable(subs, uri) {
30
+ if (typeof uri !== 'string' || !uri.startsWith('orboto://') || uri.length > MAX_SUBSCRIPTION_URI_LENGTH) {
31
+ throw new McpError(ErrorCode.InvalidParams, `Subscriptions accept orboto:// resource URIs up to ${MAX_SUBSCRIPTION_URI_LENGTH} characters`);
32
+ }
33
+ if (!subs.has(uri) && subs.size >= MAX_SUBSCRIPTIONS_PER_SESSION) {
34
+ throw new McpError(ErrorCode.InvalidParams, `A session holds at most ${MAX_SUBSCRIPTIONS_PER_SESSION} resource subscriptions`);
35
+ }
36
+ }
37
+ export function uriForLog(uri) {
38
+ return uri.length > 200 ? `${uri.slice(0, 200)}...` : uri;
39
+ }
@@ -0,0 +1,60 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
4
+ import { buildOrbotoMcpServer } from './server.js';
5
+ import { McpError } from '@modelcontextprotocol/sdk/types.js';
6
+ import { assertSubscribable, MAX_SUBSCRIPTIONS_PER_SESSION, MAX_SUBSCRIPTION_URI_LENGTH, resolveSessionLimits, sessionsOverOwnerCap, uriForLog, } from './session-limits.js';
7
+ describe('resource subscription bounds', () => {
8
+ it('accepts an orboto resource URI', () => {
9
+ expect(() => assertSubscribable(new Set(), 'orboto://ticket/ORB-1')).not.toThrow();
10
+ });
11
+ it('rejects a URI outside the orboto scheme or over the length limit', () => {
12
+ expect(() => assertSubscribable(new Set(), 'https://example.com/x')).toThrow(McpError);
13
+ expect(() => assertSubscribable(new Set(), `orboto://ticket/${'x'.repeat(MAX_SUBSCRIPTION_URI_LENGTH)}`)).toThrow(McpError);
14
+ });
15
+ it('rejects a new URI once the session holds the maximum, but re-subscribing a held one stays fine', () => {
16
+ const subs = new Set(Array.from({ length: MAX_SUBSCRIPTIONS_PER_SESSION }, (_, i) => `orboto://ticket/T-${i}`));
17
+ expect(() => assertSubscribable(subs, 'orboto://ticket/NEW-1')).toThrow(McpError);
18
+ expect(() => assertSubscribable(subs, 'orboto://ticket/T-0')).not.toThrow();
19
+ });
20
+ it('shortens long URIs in the log line', () => {
21
+ expect(uriForLog(`orboto://search/${'q'.repeat(1000)}`).length).toBeLessThan(210);
22
+ });
23
+ });
24
+ describe('resources/subscribe handler', () => {
25
+ afterEach(() => { vi.restoreAllMocks(); });
26
+ async function connect(subscriptions) {
27
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
28
+ const server = await buildOrbotoMcpServer({ baseUrl: 'https://x.test', apiKey: 'orb_k', subscriptions, toolset: 'curated' });
29
+ const client = new Client({ name: 't', version: '0' });
30
+ const [a, b] = InMemoryTransport.createLinkedPair();
31
+ await Promise.all([server.connect(a), client.connect(b)]);
32
+ return client;
33
+ }
34
+ it('stores a valid URI and refuses an oversized or foreign one without storing it', async () => {
35
+ const subs = new Set();
36
+ const client = await connect(subs);
37
+ await client.subscribeResource({ uri: 'orboto://ticket/ORB-1' });
38
+ await expect(client.subscribeResource({ uri: `orboto://search/${'x'.repeat(5000)}` })).rejects.toThrow();
39
+ await expect(client.subscribeResource({ uri: 'file:///etc/passwd' })).rejects.toThrow();
40
+ expect([...subs]).toEqual(['orboto://ticket/ORB-1']);
41
+ });
42
+ });
43
+ describe('session cap selection', () => {
44
+ it('picks the oldest sessions of the owner only', () => {
45
+ const sessions = [
46
+ { id: 'a1', userEmail: 'a', lastActiveAt: 3 },
47
+ { id: 'a2', userEmail: 'a', lastActiveAt: 1 },
48
+ { id: 'b1', userEmail: 'b', lastActiveAt: 0 },
49
+ { id: 'a3', userEmail: 'a', lastActiveAt: 2 },
50
+ ];
51
+ expect(sessionsOverOwnerCap(sessions, 'a', 2).map((s) => s.id)).toEqual(['a2', 'a3']);
52
+ expect(sessionsOverOwnerCap(sessions, 'b', 2)).toEqual([]);
53
+ });
54
+ it('reads the limits from the environment with safe fallbacks', () => {
55
+ expect(resolveSessionLimits({ ORBOTO_MCP_MAX_SESSIONS_PER_USER: '4', ORBOTO_MCP_SESSION_IDLE_MS: '1000' })).toEqual({
56
+ maxSessionsPerUser: 4, idleMs: 1000, reapIntervalMs: 1000,
57
+ });
58
+ expect(resolveSessionLimits({ ORBOTO_MCP_MAX_SESSIONS_PER_USER: 'junk' }).maxSessionsPerUser).toBe(32);
59
+ });
60
+ });
@@ -38,7 +38,7 @@ export function makeListCrossProjectLinksHandler(client) {
38
38
  }
39
39
  export const addCrossProjectLinkToolConfig = {
40
40
  title: 'Add a cross-project link between two tickets',
41
- description: 'Create a formal relation between two tickets in different projects. Caller must be a member of BOTH the source and target projects. Relation types: counterpart (parallel work in two repos - bidirectional), depends_on (this ticket waits for the other), blocks (the other waits for this), related (loose association). Pass statusSyncEnabled=true to opt into auto-close: when a counterpart-linked ticket moves to done, the other auto-closes too. Off by default.',
41
+ description: 'Create a formal relation between two tickets in different projects. The caller must be a member of both projects and read both tickets. Relation types: counterpart (parallel work, bidirectional), depends_on (this waits for the other), blocks (the other waits for this), related (loose). statusSyncEnabled=true (default off) auto-closes the other end of a counterpart link when one moves to done; it needs ticket:edit and ticket:change_status in both projects (403 otherwise). A close by a user lacking those grants or read access on the far end leaves it open, audited and notified.',
42
42
  inputSchema: z.object({
43
43
  sourceTicketKey: z.string().min(3),
44
44
  targetTicketKey: z.string().min(3),
@@ -61,7 +61,7 @@ export function makeAddCrossProjectLinkHandler(client) {
61
61
  }
62
62
  export const updateCrossProjectLinkToolConfig = {
63
63
  title: 'Toggle status-sync on a cross-project link',
64
- description: 'Flip the statusSyncEnabled flag on an existing cross-project link. When enabled on a counterpart-typed link, the other end auto-closes when this one moves to done (and vice versa). Other relation types ignore the flag at runtime, so toggling it on them is a no-op as far as status-sync goes.',
64
+ description: 'Flip statusSyncEnabled on an existing cross-project link. Enabling needs ticket:edit and ticket:change_status in both projects (403 otherwise); disabling is always allowed. On a counterpart link the other end auto-closes when one moves to done, if the closing user holds those grants and read access there. Other relation types ignore the flag.',
65
65
  inputSchema: z.object({
66
66
  sourceTicketKey: z.string().min(3),
67
67
  linkId: z.string().uuid(),
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
12
- import { makeListCrossProjectLinksHandler, makeAddCrossProjectLinkHandler, makeUpdateCrossProjectLinkHandler, makeRemoveCrossProjectLinkHandler, } from './cross-project-links.ee.js';
12
+ import { makeListCrossProjectLinksHandler, makeAddCrossProjectLinkHandler, makeUpdateCrossProjectLinkHandler, makeRemoveCrossProjectLinkHandler, addCrossProjectLinkToolConfig, updateCrossProjectLinkToolConfig, } from './cross-project-links.ee.js';
13
13
  beforeEach(() => { vi.restoreAllMocks(); });
14
14
  afterEach(() => { vi.restoreAllMocks(); });
15
15
  const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
@@ -161,3 +161,10 @@ describe('orboto_remove_cross_project_link', () => {
161
161
  await expect(makeRemoveCrossProjectLinkHandler(client)({ sourceTicketKey: 'ORB-42', linkId: LINK_ID })).rejects.toBeInstanceOf(OrbotoApiError);
162
162
  });
163
163
  });
164
+ describe('ORB-2437 status-sync grant in the tool descriptions', () => {
165
+ it('add and update name ticket:edit and ticket:change_status as the grant for enabling sync', () => {
166
+ expect(addCrossProjectLinkToolConfig.description).toContain('ticket:edit and ticket:change_status in both projects');
167
+ expect(updateCrossProjectLinkToolConfig.description).toContain('ticket:edit and ticket:change_status in both projects');
168
+ expect(addCrossProjectLinkToolConfig.description).not.toContain('Caller must be a member of BOTH');
169
+ });
170
+ });
@@ -11,7 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  export const listGitAppInstallationsToolConfig = {
13
13
  title: 'List GitHub App installations',
14
- description: 'Every GitHub App installation orboto knows, across projects (super-admin): org/user, install time, suspended state.',
14
+ description: 'Every GitHub App installation orboto knows (admin:system:read): org/user, install time, suspended state and the project keys allowed to use it.',
15
15
  inputSchema: z.object({}).shape,
16
16
  annotations: { readOnlyHint: true, idempotentHint: true },
17
17
  };
@@ -19,15 +19,17 @@ export function makeListGitAppInstallationsHandler(client) {
19
19
  return async () => {
20
20
  const rows = await client.get('/admin/git-app-installations');
21
21
  const out = rows.map((r) => ({
22
+ id: r.id,
22
23
  provider: r.provider,
23
24
  accountLogin: r.accountLogin,
24
25
  accountType: r.accountType,
25
26
  installedAt: r.installedAt,
26
27
  suspended: r.suspendedAt !== null,
28
+ projects: (r.projects ?? []).map((p) => p.key),
27
29
  }));
28
30
  const text = out.length === 0
29
31
  ? 'No GitHub App installations registered.'
30
- : out.map((r) => `- ${r.accountLogin} (${r.accountType}) - installed ${r.installedAt}${r.suspended ? ' - SUSPENDED' : ''}`).join('\n');
32
+ : out.map((r) => `- ${r.accountLogin} (${r.accountType}) - installed ${r.installedAt}${r.suspended ? ' - SUSPENDED' : ''} - projects: ${r.projects.length > 0 ? r.projects.join(', ') : 'none'}`).join('\n');
31
33
  return {
32
34
  content: [{ type: 'text', text }],
33
35
  structuredContent: { installations: out },
@@ -3,7 +3,7 @@
3
3
  * response.
4
4
  */
5
5
  import { z } from 'zod';
6
- import { budgetFor, readPayload, resolvePath, } from '../response-budget.js';
6
+ import { budgetFor, PROCESS_OWNER, readPayload, resolvePath, } from '../response-budget.js';
7
7
  export const responseExpandToolConfig = {
8
8
  title: 'Fetch the omitted part of a truncated response',
9
9
  description: 'Return the content a previous tool response omitted. When a result exceeds its response budget it comes back with a `__truncation` block containing a `handle` and the list of cut paths; pass that handle here to read the omitted remainder. Call with `handle` alone to list what is available, then with `path` (e.g. "description" or "ticketBundle.primer.markdown", or "$text" for the human-readable block) to read it, following `nextCursor` while it is not null. Handles live 15 minutes in the MCP server process - if one has expired, re-run the original tool. Read-only; returns nothing the original call did not already return.',
@@ -28,9 +28,9 @@ function describeOmitted(entries) {
28
28
  : `- ${e.path} (array, ${e.omittedItems ?? 0} of ${(e.keptItems ?? 0) + (e.omittedItems ?? 0)} items omitted)`)
29
29
  .join('\n');
30
30
  }
31
- export function makeResponseExpandHandler() {
31
+ export function makeResponseExpandHandler(owner = PROCESS_OWNER) {
32
32
  return async (input) => {
33
- const stored = readPayload(input.handle);
33
+ const stored = readPayload(input.handle, Date.now(), owner);
34
34
  if (!stored)
35
35
  return expired(input.handle);
36
36
  const omitted = stored.omitted ?? [];
@@ -11,7 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken } from './shared.js';
13
13
  import { AgentSessionScopeSchema } from './agent-session-scope.js';
14
- import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
14
+ import { PROCESS_OWNER, PROTECT_TEXT_META, storePayload } from '../response-budget.js';
15
15
  import { loadRequiredRules } from '../required-rules.js';
16
16
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
17
17
  import { channelStartLines } from '../inbox-channel.js';
@@ -135,7 +135,7 @@ async function buildTicketBundle(client, ticketKey) {
135
135
  }
136
136
  /** ORB-2225 - the Knowledge block stays small; the rest is one expand away. */
137
137
  export const KNOWLEDGE_BLOCK_CHARS = 1500;
138
- export function knowledgeLines(text) {
138
+ export function knowledgeLines(text, owner = PROCESS_OWNER) {
139
139
  if (text.length <= KNOWLEDGE_BLOCK_CHARS)
140
140
  return { lines: text.split('\n') };
141
141
  const cut = text.slice(0, KNOWLEDGE_BLOCK_CHARS);
@@ -143,7 +143,7 @@ export function knowledgeLines(text) {
143
143
  const handle = storePayload('orboto_session_start', {
144
144
  structuredContent: { knowledge: text }, text,
145
145
  omitted: [{ path: 'knowledge', kind: 'string', omittedChars: text.length - kept.length }],
146
- });
146
+ }, Date.now(), owner);
147
147
  return {
148
148
  handle,
149
149
  lines: [...kept.split('\n'), `(Knowledge block cut at ${kept.length} of ${text.length} characters: orboto_response_expand { handle: "${handle}", path: "knowledge" }, or GET /knowledge.)`],
@@ -270,7 +270,7 @@ export function makeSessionStartHandler(client, opts = {}) {
270
270
  structuredContent: { rules: rules.instructions ?? '' },
271
271
  text: rules.instructions ?? '',
272
272
  omitted: [{ path: 'rules', kind: 'string', omittedChars: rulesText.length }],
273
- });
273
+ }, Date.now(), client);
274
274
  lines.push(`${rulesIndex.length} rule(s) bind you (${rulesChars} characters, hash ${rules.rulesHash}). Titles only - the full text is NOT in this response.`, `${HOW_TO_READ_RULES} Read any rule below whose title touches what you are about to do BEFORE you do it. (orboto_response_expand { handle: "${rulesHandle}", path: "rules" } serves the same text from this process for 15 minutes.)`, ...rulesIndex.map((title, i) => `${i + 1}. ${title}`));
275
275
  }
276
276
  else {
@@ -295,7 +295,7 @@ export function makeSessionStartHandler(client, opts = {}) {
295
295
  }
296
296
  lines.push('', '## Timer');
297
297
  lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
298
- const knowledgeBlock = knowledge?.text ? knowledgeLines(knowledge.text) : null;
298
+ const knowledgeBlock = knowledge?.text ? knowledgeLines(knowledge.text, client) : null;
299
299
  if (knowledgeBlock)
300
300
  lines.push('', ...knowledgeBlock.lines);
301
301
  lines.push('', '## This session');
@@ -453,12 +453,12 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
453
453
  stubDigest(rulesFull);
454
454
  const res = await makeSessionStartHandler(client)();
455
455
  const { rulesHandle } = res.structuredContent;
456
- const expand = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules' });
456
+ const expand = await makeResponseExpandHandler(client)({ handle: rulesHandle, path: 'rules' });
457
457
  const chunks = [];
458
458
  let cursor = 0;
459
459
  let guard = 0;
460
460
  while (cursor !== null && guard++ < 20) {
461
- const page = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules', cursor });
461
+ const page = await makeResponseExpandHandler(client)({ handle: rulesHandle, path: 'rules', cursor });
462
462
  const s = page.structuredContent;
463
463
  chunks.push(s.chunk);
464
464
  cursor = s.nextCursor;
@@ -719,7 +719,7 @@ export function makeRemoveTicketDependencyHandler(client) {
719
719
  }
720
720
  export const listTicketDependenciesToolConfig = {
721
721
  title: 'List a ticket\'s dependencies',
722
- description: 'Show both directions of a ticket\'s dependency graph: `blockedBy` (must finish first) and `blocks` (waiting on this one). ORB-1614: an edge to a ticket in another project you cannot read comes back as an opaque "external dependency" entry (no title/key/status - just whether it is still open).',
722
+ description: 'Show both directions of a ticket\'s dependency graph: `blockedBy` (must finish first) and `blocks` (waiting on this one). ORB-1614: an edge to a ticket you cannot read comes back as an opaque stub (no title/key/status - just whether it is still open); `external` marks another project.',
723
723
  inputSchema: z.object({
724
724
  ticketKey: z.string().min(3),
725
725
  }).shape,
@@ -61,7 +61,7 @@ export function withMetrics(client, toolName, clientHint, handler, nudge) {
61
61
  const label = metricsToolName(toolName, args);
62
62
  try {
63
63
  const handlerResult = await handler(args, extra);
64
- const budgeted = applyResponseBudget(toolName, handlerResult);
64
+ const budgeted = applyResponseBudget(toolName, handlerResult, process.env, client);
65
65
  const result = budgeted.result;
66
66
  const inner = label === toolName ? undefined : envelopeStatus(handlerResult.structuredContent);
67
67
  const isError = result.isError === true || (inner !== undefined && inner >= 400);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.212.4",
3
+ "version": "0.212.5",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",