@orboto/mcp 0.229.0 → 0.230.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.
- package/dist/http-transport-binding.test.js +2 -2
- package/dist/http-transport.test.js +5 -5
- package/dist/inbox-channel.js +15 -0
- package/dist/inbox-channel.test.js +50 -1
- package/dist/index.js +12 -4
- package/dist/session-origin.test.js +2 -2
- package/dist/tools/api-call.js +3 -1
- package/dist/tools/git-rotate-token.js +1 -1
- package/dist/tools/presence-scope.test.js +16 -3
- package/dist/tools/sentry.js +1 -1
- package/dist/tools/session-start.js +19 -3
- package/dist/tools/shared.js +19 -1
- package/dist/tools/time-writes.js +8 -2
- package/dist/tools/time-writes.test.js +6 -0
- package/package.json +1 -1
|
@@ -71,10 +71,10 @@ const store = {
|
|
|
71
71
|
};
|
|
72
72
|
async function start(limits) {
|
|
73
73
|
api = fakeApiPerUser();
|
|
74
|
-
await new Promise((r) => api.listen(0, r));
|
|
74
|
+
await new Promise((r) => api.listen(0, '127.0.0.1', r));
|
|
75
75
|
const { port: apiPort } = api.address();
|
|
76
76
|
server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store, limits });
|
|
77
|
-
await new Promise((r) => server.listen(0, r));
|
|
77
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
78
78
|
const { port } = server.address();
|
|
79
79
|
return `http://127.0.0.1:${port}`;
|
|
80
80
|
}
|
|
@@ -12,7 +12,7 @@ let server = null;
|
|
|
12
12
|
afterEach(() => { server?.close(); server = null; });
|
|
13
13
|
async function start() {
|
|
14
14
|
server = createHttpServer({ baseUrl: 'http://api.invalid' });
|
|
15
|
-
await new Promise((resolve) => server.listen(0, resolve));
|
|
15
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
16
16
|
const { port } = server.address();
|
|
17
17
|
return `http://127.0.0.1:${port}`;
|
|
18
18
|
}
|
|
@@ -226,10 +226,10 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
|
|
|
226
226
|
});
|
|
227
227
|
async function startWithFakeApi(store, fake = fakeApi()) {
|
|
228
228
|
api = fake.api;
|
|
229
|
-
await new Promise((r) => api.listen(0, r));
|
|
229
|
+
await new Promise((r) => api.listen(0, '127.0.0.1', r));
|
|
230
230
|
const { port: apiPort } = api.address();
|
|
231
231
|
server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
|
|
232
|
-
await new Promise((r) => server.listen(0, r));
|
|
232
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
233
233
|
const { port } = server.address();
|
|
234
234
|
const internals = server.__mcp;
|
|
235
235
|
return { base: `http://127.0.0.1:${port}`, internals };
|
|
@@ -330,10 +330,10 @@ describe('ORB-1470 - per-session bearer follows the client\'s token rotation', (
|
|
|
330
330
|
});
|
|
331
331
|
async function startWithFakeApi(store, fake = fakeApi()) {
|
|
332
332
|
api = fake.api;
|
|
333
|
-
await new Promise((r) => api.listen(0, r));
|
|
333
|
+
await new Promise((r) => api.listen(0, '127.0.0.1', r));
|
|
334
334
|
const { port: apiPort } = api.address();
|
|
335
335
|
server = createHttpServer({ baseUrl: `http://127.0.0.1:${apiPort}`, sessionStore: store });
|
|
336
|
-
await new Promise((r) => server.listen(0, r));
|
|
336
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
337
337
|
const { port } = server.address();
|
|
338
338
|
const internals = server.__mcp;
|
|
339
339
|
return { base: `http://127.0.0.1:${port}`, internals, state: fake.state };
|
package/dist/inbox-channel.js
CHANGED
|
@@ -230,6 +230,19 @@ export class InboxChannel {
|
|
|
230
230
|
}
|
|
231
231
|
await this.emit({ content, meta: { kind: 'notice', notice } });
|
|
232
232
|
}
|
|
233
|
+
/** ORB-2641 - follow the session this process joined: the stream reconnects under its token and replays that session's unread mail, also what is older than the last id this process saw. */
|
|
234
|
+
switchInstance(token) {
|
|
235
|
+
if (!token || token === this.opts.instanceToken)
|
|
236
|
+
return;
|
|
237
|
+
this.opts = { ...this.opts, instanceToken: token };
|
|
238
|
+
this.lastId = null;
|
|
239
|
+
this.since = JOINED_REPLAY_SINCE;
|
|
240
|
+
this.log(`joined the session of this process under its token; the inbox stream reconnects`);
|
|
241
|
+
try {
|
|
242
|
+
this.abort.abort();
|
|
243
|
+
}
|
|
244
|
+
catch { /* ignore */ }
|
|
245
|
+
}
|
|
233
246
|
/** ORB-2209 - the id and scope the server resumed; never a channel event. */
|
|
234
247
|
applySession(session) {
|
|
235
248
|
this.session = session;
|
|
@@ -360,6 +373,8 @@ export class InboxChannel {
|
|
|
360
373
|
}
|
|
361
374
|
}
|
|
362
375
|
}
|
|
376
|
+
/** ORB-2641 - the replay anchor after joining another session: its whole unread backlog, deduplicated by message id. */
|
|
377
|
+
export const JOINED_REPLAY_SINCE = '2020-01-01T00:00:00.000Z';
|
|
363
378
|
/** ORB-2175 - the notice name the stale-CLI finding is delivered under. */
|
|
364
379
|
export const CLI_OUTDATED_NOTICE = 'cli_outdated';
|
|
365
380
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
7
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
8
|
-
import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent, scopeHintWithKey } from './inbox-channel.js';
|
|
8
|
+
import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, JOINED_REPLAY_SINCE, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent, scopeHintWithKey } from './inbox-channel.js';
|
|
9
9
|
import { buildOrbotoMcpServer } from './server.js';
|
|
10
10
|
import { _forgetDeclaredScopes, rememberDeclaredScope, rememberedScope } from './session-scope-memory.js';
|
|
11
11
|
function mockMcp() {
|
|
@@ -292,3 +292,52 @@ describe('server capability', () => {
|
|
|
292
292
|
await off.server.close();
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
|
+
describe('ORB-2641 - a proxy that joins the session of its process', () => {
|
|
296
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
297
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
298
|
+
it('reconnects the inbox stream under the joined token, and every later call of the process acts under it', async () => {
|
|
299
|
+
const { mcp } = mockMcp();
|
|
300
|
+
const held = (_url, init) => new Promise((_resolve, reject) => {
|
|
301
|
+
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
|
|
302
|
+
});
|
|
303
|
+
const fetchFn = vi.fn().mockImplementation(held);
|
|
304
|
+
const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'agent-checkout', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
|
|
305
|
+
channel.start();
|
|
306
|
+
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
|
|
307
|
+
channel.switchInstance('agent-checkout');
|
|
308
|
+
channel.switchInstance('agent-checkout-ns');
|
|
309
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
310
|
+
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
|
|
311
|
+
expect(fetchFn.mock.calls[0][1].headers['x-orboto-agent-session']).toBe('agent-checkout');
|
|
312
|
+
expect(fetchFn.mock.calls[1][1].headers['x-orboto-agent-session']).toBe('agent-checkout-ns');
|
|
313
|
+
expect(String(fetchFn.mock.calls[1][0])).toContain(`since=${encodeURIComponent(JOINED_REPLAY_SINCE)}`);
|
|
314
|
+
channel.close();
|
|
315
|
+
const { adoptProcessInstance, mcpProcessInstance, mcpInstanceToken } = await import('./tools/shared.js');
|
|
316
|
+
const before = mcpProcessInstance();
|
|
317
|
+
adoptProcessInstance('bad token with spaces');
|
|
318
|
+
expect(mcpProcessInstance()).toBe(before);
|
|
319
|
+
adoptProcessInstance('agent-checkout-ns');
|
|
320
|
+
expect(mcpProcessInstance()).toBe('agent-checkout-ns');
|
|
321
|
+
expect(mcpInstanceToken()).toBe('agent-checkout-ns');
|
|
322
|
+
adoptProcessInstance(before);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('ORB-2641 - a server without a channel joins the session of its process too', () => {
|
|
326
|
+
it('its one tool-server beat adopts the joined token, and an answer without one changes nothing', async () => {
|
|
327
|
+
const { beatAsToolServer, mcpProcessInstance, adoptProcessInstance } = await import('./tools/shared.js');
|
|
328
|
+
const before = mcpProcessInstance();
|
|
329
|
+
const seen = [];
|
|
330
|
+
const client = (joined) => ({
|
|
331
|
+
post: async (_path, body, options) => {
|
|
332
|
+
seen.push({ body, token: options?.instanceToken });
|
|
333
|
+
return (joined ? { sessionId: 's', joined: { sessionId: 's', shortId: 's', instanceToken: joined } } : { sessionId: 's' });
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
expect(await beatAsToolServer(client(), { toolServer: true })).toBeNull();
|
|
337
|
+
expect(mcpProcessInstance()).toBe(before);
|
|
338
|
+
expect(await beatAsToolServer(client('agent-cli-declared'), { toolServer: true })).toBe('agent-cli-declared');
|
|
339
|
+
expect(mcpProcessInstance()).toBe('agent-cli-declared');
|
|
340
|
+
expect(seen).toEqual([{ body: { clientInfo: { toolServer: true } }, token: before }, { body: { clientInfo: { toolServer: true } }, token: before }]);
|
|
341
|
+
adoptProcessInstance(before);
|
|
342
|
+
});
|
|
343
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -67,8 +67,8 @@ async function main() {
|
|
|
67
67
|
const releaseCapacity = releaseCapacityOnStdinEnd(process.stdin, preflightClient, (await import('./tools/shared.js')).mcpProcessInstance());
|
|
68
68
|
if (channel) {
|
|
69
69
|
const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
|
|
70
|
-
const { mcpProcessInstance } = await import('./tools/shared.js');
|
|
71
|
-
|
|
70
|
+
const { mcpProcessInstance, adoptJoined } = await import('./tools/shared.js');
|
|
71
|
+
let instanceToken = mcpProcessInstance();
|
|
72
72
|
const { declareEnvProjectScope } = await import('./project-scope.js');
|
|
73
73
|
const declared = await declareEnvProjectScope(preflightClient, instanceToken);
|
|
74
74
|
const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
|
|
@@ -83,7 +83,14 @@ async function main() {
|
|
|
83
83
|
let refusalLogged = false;
|
|
84
84
|
const beat = () => {
|
|
85
85
|
preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined) }, { instanceToken })
|
|
86
|
-
.then(() => {
|
|
86
|
+
.then((res) => {
|
|
87
|
+
refusalLogged = false;
|
|
88
|
+
const joined = adoptJoined(res);
|
|
89
|
+
if (joined) {
|
|
90
|
+
instanceToken = joined;
|
|
91
|
+
inbox.switchInstance(joined);
|
|
92
|
+
}
|
|
93
|
+
})
|
|
87
94
|
.catch((err) => {
|
|
88
95
|
const refused = presenceRefusal(err);
|
|
89
96
|
if (refused && refusalLogged)
|
|
@@ -102,7 +109,8 @@ async function main() {
|
|
|
102
109
|
}
|
|
103
110
|
else {
|
|
104
111
|
const { toolServerHeartbeat } = await import('./claude-context.js');
|
|
105
|
-
|
|
112
|
+
const { beatAsToolServer } = await import('./tools/shared.js');
|
|
113
|
+
beatAsToolServer(preflightClient, toolServerHeartbeat())
|
|
106
114
|
.catch((err) => { console.error(`[orboto-mcp] heartbeat failed: ${err.message}`); });
|
|
107
115
|
}
|
|
108
116
|
return;
|
|
@@ -111,10 +111,10 @@ describe('ORB-2544 - an MCP client reconnecting five times', () => {
|
|
|
111
111
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
112
112
|
res.end(url.startsWith('/agent-instructions') ? JSON.stringify({ instructions: '' }) : '{}');
|
|
113
113
|
});
|
|
114
|
-
await new Promise((r) => api.listen(0, r));
|
|
114
|
+
await new Promise((r) => api.listen(0, '127.0.0.1', r));
|
|
115
115
|
const store = { register: async () => { }, resolve: async () => false, remove: async () => { } };
|
|
116
116
|
server = createHttpServer({ baseUrl: `http://127.0.0.1:${api.address().port}`, sessionStore: store });
|
|
117
|
-
await new Promise((r) => server.listen(0, r));
|
|
117
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
118
118
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
119
119
|
const init = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } } });
|
|
120
120
|
for (let i = 0; i < 5; i += 1) {
|
package/dist/tools/api-call.js
CHANGED
|
@@ -16,7 +16,9 @@ export const apiCallToolConfig = {
|
|
|
16
16
|
+ 'Find the endpoint + its schema with `orboto_api_search` first. Runs with YOUR permissions '
|
|
17
17
|
+ 'only (the API enforces them server-side); the response is an envelope carrying the inner '
|
|
18
18
|
+ 'HTTP status + body verbatim, so react to a 403/404/422 as data. Auth/OAuth/setup/webhook '
|
|
19
|
-
+ 'paths are blocked at the proxy.
|
|
19
|
+
+ 'paths are blocked at the proxy. Over an OAuth connection, API keys, OAuth clients, sign-in factors and passwords '
|
|
20
|
+
+ 'need the web login (403 errors.auth.oauth_credential_route_refused). '
|
|
21
|
+
+ 'Prefer named tools when one exists - they are more ergonomic.',
|
|
20
22
|
inputSchema: z.object({
|
|
21
23
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
|
22
24
|
path: z.string().min(1).max(500)
|
|
@@ -10,7 +10,7 @@ import { z } from 'zod';
|
|
|
10
10
|
import { resolveProjectByKey } from './shared.js';
|
|
11
11
|
export const gitRotateTokenToolConfig = {
|
|
12
12
|
title: 'Rotate a git connection\'s access token',
|
|
13
|
-
description: 'Replace a git connection\'s access token (ssh: private key). The provider is probed with the new credential first; on success it is re-sealed under the same connection id, on failure 422 and the old one stays. reinstallWebhook also reinstalls the hook. App / OAuth connections answer 422.',
|
|
13
|
+
description: 'Replace a git connection\'s access token (ssh: private key). The provider is probed with the new credential first; on success it is re-sealed under the same connection id, on failure 422 and the old one stays. reinstallWebhook also reinstalls the hook. App / OAuth connections answer 422. Needs an API key or the web login: an OAuth MCP connection gets 403 errors.auth.oauth_credential_route_refused.',
|
|
14
14
|
inputSchema: z.object({
|
|
15
15
|
projectKey: z.string().min(1).describe('Project key, e.g. ORB.'),
|
|
16
16
|
connectionId: z.string().uuid().describe('Git connection id.'),
|
|
@@ -18,9 +18,9 @@ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs';
|
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
const SESSION_EXISTS = {
|
|
21
|
-
error: 'Another live session of this account runs in sample-repo on build-host: 0123abcd (claude-code, 42 min, ORB-7). Start again with --adopt to take over
|
|
21
|
+
error: 'Another live session of this account runs in sample-repo on build-host with an overlapping scope (ORB): 0123abcd (claude-code, 42 min, ORB-7). Start with a scope that shares none of these keys to run both side by side, or again with --adopt to take over the scope, mail and leases of every overlapping session (0123abcd, 4567ef01, 89ab0123), or with --end-previous to end them.',
|
|
22
22
|
errorKey: 'errors.agents.session_exists',
|
|
23
|
-
errorParams: { sessionId: '0123abcd-0000-4000-8000-000000000000', shortId: '0123abcd', ageMinutes: 42, ticket: 'ORB-7', host: 'build-host', repo: 'sample-repo', harness: 'claude-code', role: 'integrator', projects: 'ORB' },
|
|
23
|
+
errorParams: { sessionId: '0123abcd-0000-4000-8000-000000000000', shortId: '0123abcd', ageMinutes: 42, ticket: 'ORB-7', host: 'build-host', repo: 'sample-repo', harness: 'claude-code', role: 'integrator', projects: 'ORB', overlap: 'ORB', sessionIds: '0123abcd, 4567ef01, 89ab0123', alsoOverlapping: '4567ef01, 89ab0123' },
|
|
24
24
|
};
|
|
25
25
|
const SCOPE_REQUIRED = { error: 'This session declares no scope', errorKey: 'errors.agents.scope_required', errorParams: { command: 'orboto session-start --role <role> --scope-projects <KEY>', notes: ['Not carried over: the earlier scope.'] } };
|
|
26
26
|
beforeEach(() => { _forgetDeclaredScopes(); });
|
|
@@ -55,7 +55,9 @@ describe('ORB-2553 - the refusal on the MCP side', () => {
|
|
|
55
55
|
const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['ORB'] }, previous: 'adopt' }, { sessionId: 'ps1' });
|
|
56
56
|
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ start: true, previous: 'adopt', scope: { role: 'integrator', projectKeys: ['ORB'] } });
|
|
57
57
|
const text = res.content[0].text;
|
|
58
|
-
expect(text).toContain('Not registered: Another live session of this account runs in sample-repo on build-host: 0123abcd');
|
|
58
|
+
expect(text).toContain('Not registered: Another live session of this account runs in sample-repo on build-host with an overlapping scope (ORB): 0123abcd');
|
|
59
|
+
expect(text).toContain('once the scope shares none of its keys');
|
|
60
|
+
expect(text).toContain('to replace it and 2 more: 4567ef01, 89ab0123 - all of them');
|
|
59
61
|
expect(text).toContain('previous: "adopt" or "end"');
|
|
60
62
|
expect(text).not.toContain('Peers reach exactly this session');
|
|
61
63
|
expect(res.structuredContent.session.refused).toContain('0123abcd');
|
|
@@ -69,6 +71,17 @@ describe('ORB-2553 - the refusal on the MCP side', () => {
|
|
|
69
71
|
expect(res.content[0].text).toContain('Adopted session 0123abcd: 2 unread message(s) and the leases on ORB-7 moved here, it is ended.');
|
|
70
72
|
expect(takeoverLine({ action: 'ended', shortId: 'abcd0123', movedMessages: 0, movedLeases: [], releasedTickets: ['ORB-9'] })).toBe('Ended the previous session abcd0123 in this repository; its leases on ORB-9 are released.');
|
|
71
73
|
});
|
|
74
|
+
it('ORB-2639 - a start that took over several overlapping sessions names each', async () => {
|
|
75
|
+
const seen = [];
|
|
76
|
+
const one = { action: 'ended', sessionId: '0123abcd-0000-4000-8000-000000000000', shortId: '0123abcd', movedMessages: 0, movedLeases: [], releasedTickets: [] };
|
|
77
|
+
const two = { ...one, sessionId: '4567ef01-0000-4000-8000-000000000000', shortId: '4567ef01', releasedTickets: ['IMG-57'] };
|
|
78
|
+
stub({ status: 200, body: { sessionToken: 'mcp-ps3', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'lead', projectKeys: ['WEB', 'IMG'] }, takeover: one, takeovers: [one, two] } }, seen);
|
|
79
|
+
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
|
|
80
|
+
const res = await makeSessionStartHandler(client)({ previous: 'end' }, { sessionId: 'ps3' });
|
|
81
|
+
const text = res.content[0].text;
|
|
82
|
+
expect(text).toContain('Ended the previous session 0123abcd in this repository.');
|
|
83
|
+
expect(text).toContain('Ended the previous session 4567ef01 in this repository; its leases on IMG-57 are released.');
|
|
84
|
+
});
|
|
72
85
|
it('the proxy declares the checkout project as a start and hands back the refusal', async () => {
|
|
73
86
|
const calls = [];
|
|
74
87
|
const refusing = {
|
package/dist/tools/sentry.js
CHANGED
|
@@ -9,7 +9,7 @@ import { mcpInstanceToken, resolveProjectByKey, resolveTicketByKey } from './sha
|
|
|
9
9
|
const ACTIONS = ['list', 'health', 'connect', 'update', 'rotate_secret', 'disconnect', 'triage_lane', 'verdict'];
|
|
10
10
|
export const sentryToolConfig = {
|
|
11
11
|
title: 'Sentry connector',
|
|
12
|
-
description: 'Sentry connector (ORB-2123). Actions: list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
|
|
12
|
+
description: 'Sentry connector (ORB-2123). Actions: list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md. connect and rotate_secret need an API key or the web login (403 over OAuth).',
|
|
13
13
|
inputSchema: z.object({
|
|
14
14
|
action: z.enum(ACTIONS),
|
|
15
15
|
projectKey: z.string().min(1),
|
|
@@ -23,7 +23,7 @@ import { agentContextKey } from '../agent-context.js';
|
|
|
23
23
|
export const sessionStartToolConfig = {
|
|
24
24
|
title: 'Load the rules you must follow + re-orient',
|
|
25
25
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
|
|
26
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). `previous` (adopt | end) answers a second-start refusal (ORB-2553). '
|
|
26
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). `previous` (adopt | end) answers a second-start refusal (ORB-2553); it comes only when the scope overlaps another live session of the account in this checkout (the keys in errorParams.overlap) - a scope that shares no project or ticket with it runs beside it (ORB-2639). '
|
|
27
27
|
+ 'Also carries the Knowledge block (ORB-2225: the wiki index of your declared projects and the Operations wiki; ask with orboto_knowledge_ask before a person, file durable facts with orboto_knowledge_add) and the Responsibilities block (ORB-2542: who owns the topics of your projects, orboto_who before agent mail). Right after the rules come Where knowledge lives (ORB-2575: rules bind behaviour, project facts hold fixed decisions, the wiki the long tail; a decision goes to a fact proposal, orboto_primer_fact_add with observed: true) and the Project facts of your declared projects.',
|
|
28
28
|
inputSchema: z.object({
|
|
29
29
|
projectId: z.string().uuid().optional(),
|
|
@@ -177,6 +177,11 @@ function knowledgeSummary(index, handle) {
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
/** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
|
|
180
|
+
/** ORB-2639 - what `previous` replaces: the session the refusal names, and every further overlapping one by short id. */
|
|
181
|
+
export function overlappingTarget(alsoOverlapping) {
|
|
182
|
+
const ids = alsoOverlapping.split(/[\s,]+/).filter(Boolean);
|
|
183
|
+
return ids.length ? `it and ${ids.length} more: ${ids.join(', ')} - all of them` : 'it';
|
|
184
|
+
}
|
|
180
185
|
/** ORB-2553 - what the start did to the session it ran into. */
|
|
181
186
|
export function takeoverLine(t) {
|
|
182
187
|
if (t.action === 'ended')
|
|
@@ -232,9 +237,17 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
232
237
|
sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
|
|
233
238
|
wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
|
|
234
239
|
...(r.takeover ? { takeover: r.takeover } : {}),
|
|
240
|
+
...(r.takeovers?.length ? { takeovers: r.takeovers } : {}),
|
|
235
241
|
} : null);
|
|
236
242
|
let refusal = null;
|
|
237
|
-
|
|
243
|
+
let alsoOverlapping = '';
|
|
244
|
+
const refused = (err) => {
|
|
245
|
+
const found = presenceRefusal(err);
|
|
246
|
+
refusal = found?.message ?? refusal;
|
|
247
|
+
if (found && typeof found.params.alsoOverlapping === 'string')
|
|
248
|
+
alsoOverlapping = found.params.alsoOverlapping.trim();
|
|
249
|
+
return null;
|
|
250
|
+
};
|
|
238
251
|
const host = localSessionHost(extra);
|
|
239
252
|
const fallbackScope = input.scope === undefined && !supervisorScopePending() ? envProjectScope() : null;
|
|
240
253
|
const startBody = {
|
|
@@ -368,11 +381,13 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
368
381
|
? 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it'
|
|
369
382
|
: 'registration failed - only account mail is listed; retry orboto_session_start or check mcp:use';
|
|
370
383
|
if (refusal)
|
|
371
|
-
lines.push(`ref ${instanceToken}. Not registered: ${refusal}`,
|
|
384
|
+
lines.push(`ref ${instanceToken}. Not registered: ${refusal}`, `With MCP: pass scope: { role, projectKeys }; another live session in this repository whose scope overlaps runs beside this one once the scope shares none of its keys, or pass previous: "adopt" or "end" to replace ${overlappingTarget(alsoOverlapping)}.`);
|
|
372
385
|
else
|
|
373
386
|
lines.push(`ref ${instanceToken}${registration ? `, instance ${registration.sessionId.slice(0, 8)}` : ''}. ${scopeText}. Peers reach exactly this session with orboto_agent_notify { toSessionRef: "${registration ? registration.sessionId.slice(0, 8) : instanceToken}" }.`);
|
|
374
387
|
if (exitedLine)
|
|
375
388
|
lines.push(exitedLine);
|
|
389
|
+
else if (registration?.takeovers?.length)
|
|
390
|
+
lines.push(...registration.takeovers.map(takeoverLine));
|
|
376
391
|
else if (registration?.takeover)
|
|
377
392
|
lines.push(takeoverLine(registration.takeover));
|
|
378
393
|
const roleAddresses = registration?.scope?.role ? (registration.scope.projectKeys ?? []).map((k) => `${registration.scope.role}@${k}`) : [];
|
|
@@ -420,6 +435,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
420
435
|
scopeContinued: registration?.scopeContinued ?? null,
|
|
421
436
|
...(refusal ? { refused: refusal } : {}),
|
|
422
437
|
...(registration?.takeover ? { takeover: registration.takeover } : {}),
|
|
438
|
+
...(registration?.takeovers?.length ? { takeovers: registration.takeovers } : {}),
|
|
423
439
|
},
|
|
424
440
|
gitHealth: {
|
|
425
441
|
unhealthy: gitHealthWithConnections
|
package/dist/tools/shared.js
CHANGED
|
@@ -122,7 +122,25 @@ export function checkoutInstanceToken(env = process.env, host = hostname(), cwd
|
|
|
122
122
|
const explicit = (env.ORBOTO_AGENT_SESSION ?? '').trim();
|
|
123
123
|
return explicit || `agent-${createHash('sha256').update(`${host}:${cwd}`).digest('hex').slice(0, 12)}`;
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
let MCP_PROCESS_INSTANCE = checkoutInstanceToken();
|
|
126
|
+
/** ORB-2641 - the process joins the session its harness process already declared under another token: every later call acts under that token. */
|
|
127
|
+
export function adoptProcessInstance(token) {
|
|
128
|
+
if (/^[\x21-\x7e]{1,200}$/.test(token))
|
|
129
|
+
MCP_PROCESS_INSTANCE = token;
|
|
130
|
+
}
|
|
131
|
+
/** ORB-2641 - a heartbeat answer that names the session of this process (`joined`): the process continues under its token; returns that token, or null when nothing changed. */
|
|
132
|
+
export function adoptJoined(res) {
|
|
133
|
+
const token = res?.joined?.instanceToken;
|
|
134
|
+
if (!token || token === MCP_PROCESS_INSTANCE)
|
|
135
|
+
return null;
|
|
136
|
+
adoptProcessInstance(token);
|
|
137
|
+
return MCP_PROCESS_INSTANCE === token ? token : null;
|
|
138
|
+
}
|
|
139
|
+
/** ORB-2641 - the one heartbeat of a stdio server without a channel: it says so, and joins the session of its process when the answer names one. */
|
|
140
|
+
export async function beatAsToolServer(client, clientInfo) {
|
|
141
|
+
const res = await client.post('/v1/agent/heartbeat', { clientInfo }, { instanceToken: MCP_PROCESS_INSTANCE });
|
|
142
|
+
return adoptJoined(res);
|
|
143
|
+
}
|
|
126
144
|
/** ORB-2465 - the machine a stdio proxy runs on, lower-cased, as `agent-broadcast live --host` matches it; an HTTP connection (per-connection session id) runs on the server, so it reports none. */
|
|
127
145
|
export function localSessionHost(extra, host = hostname()) {
|
|
128
146
|
if (extra?.sessionId)
|
|
@@ -6,7 +6,7 @@ import { OrbotoApiError } from '../orboto-client.js';
|
|
|
6
6
|
import { resolveTicketByKey } from './shared.js';
|
|
7
7
|
export const timerStartToolConfig = {
|
|
8
8
|
title: 'Start a timer on a ticket',
|
|
9
|
-
description: 'Start the user\'s stopwatch on a ticket. If a timer is already running on a different ticket, pass replace=true to auto-stop the previous one (its elapsed time is committed to time_entries before the new timer begins). Description becomes the time entry\'s note when this timer eventually stops.',
|
|
9
|
+
description: 'Start the user\'s stopwatch on a ticket. If a timer is already running on a different ticket, pass replace=true to auto-stop the previous one (its elapsed time is committed to time_entries before the new timer begins). Description becomes the time entry\'s note when this timer eventually stops. Needs time:log on the ticket\'s project (403 errors.time.log_permission_required) and a ticket you can read (404).',
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
ticketKey: z.string().min(3),
|
|
12
12
|
description: z.string().optional().describe('Note for the time entry; set at start, not at stop.'),
|
|
@@ -46,7 +46,7 @@ export function makeTimerStartHandler(client) {
|
|
|
46
46
|
}
|
|
47
47
|
export const timerStopToolConfig = {
|
|
48
48
|
title: 'Stop the running timer',
|
|
49
|
-
description: 'Stop the user\'s stopwatch. The API commits a time_entries row using the description set at start (or pause/resume), then deletes the active-timer record. Returns the duration in minutes.',
|
|
49
|
+
description: 'Stop the user\'s stopwatch. The API commits a time_entries row using the description set at start (or pause/resume), then deletes the active-timer record. Returns the duration in minutes; bookingRefused: true means the timer ran but booked nothing because you can no longer read the ticket or lost time:log on its project.',
|
|
50
50
|
inputSchema: z.object({}).shape,
|
|
51
51
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
52
52
|
};
|
|
@@ -60,6 +60,12 @@ export function makeTimerStopHandler(client) {
|
|
|
60
60
|
structuredContent: { durationMinutes: 0, alreadyStopped: true },
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
if (res.bookingRefused) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: 'Timer stopped - nothing booked: you can no longer read the ticket or lost time:log on its project.' }],
|
|
66
|
+
structuredContent: { durationMinutes: 0, bookingRefused: true },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
63
69
|
return {
|
|
64
70
|
content: [{
|
|
65
71
|
type: 'text',
|
|
@@ -74,6 +74,12 @@ describe('orboto_timer_stop', () => {
|
|
|
74
74
|
const sc = res.structuredContent;
|
|
75
75
|
expect(sc.alreadyStopped).toBe(true);
|
|
76
76
|
});
|
|
77
|
+
it('ORB-2632 - says nothing was booked when the API refused the booking', async () => {
|
|
78
|
+
stub([{ json: { durationMinutes: 0, stopped: true, laneFallback: false, bookingRefused: true } }]);
|
|
79
|
+
const res = await makeTimerStopHandler(client)();
|
|
80
|
+
expect(res.structuredContent).toEqual({ durationMinutes: 0, bookingRefused: true });
|
|
81
|
+
expect(res.content[0].text).toContain('nothing booked');
|
|
82
|
+
});
|
|
77
83
|
});
|
|
78
84
|
describe('orboto_log_time', () => {
|
|
79
85
|
it('POSTs durationMinutes + optional description', async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.230.0",
|
|
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",
|