@orboto/mcp 0.211.3 → 0.212.1
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.js +3 -0
- package/dist/http-transport.test.js +33 -0
- package/dist/index.js +3 -1
- package/dist/manifest-size.test.js +2 -0
- package/dist/oauth-bootstrap.js +119 -35
- package/dist/oauth-bootstrap.test.js +118 -2
- package/dist/probe-session.js +3 -1
- package/dist/probe-session.test.js +11 -2
- package/dist/resources.js +8 -0
- package/dist/server.js +2 -0
- package/dist/tail-labels.js +30 -0
- package/dist/tool-parameters.test.js +5 -4
- package/dist/tools/agent-coordination.js +50 -8
- package/dist/tools/agent-coordination.test.js +42 -0
- package/dist/tools/agent-messages-unregistered.test.js +24 -0
- package/dist/tools/agent-messages.js +4 -1
- package/dist/tools/agent-who.js +34 -0
- package/dist/tools/agent-who.test.js +31 -0
- package/dist/tools/bulk-writes.js +2 -2
- package/dist/tools/capacity-guide.js +46 -0
- package/dist/tools/capacity-session.js +109 -0
- package/dist/tools/capacity-session.test.js +179 -0
- package/dist/tools/capacity-tail.test.js +152 -0
- package/dist/tools/docs.js +4 -4
- package/dist/tools/help.js +4 -3
- package/dist/tools/live-transcript.js +93 -0
- package/dist/tools/live-transcript.test.js +49 -0
- package/dist/tools/session-check.js +20 -5
- package/dist/tools/session-check.test.js +3 -3
- package/dist/tools/session-start.js +6 -1
- package/dist/tools/session-start.test.js +16 -0
- package/dist/with-metrics.js +9 -5
- package/package.json +1 -1
package/dist/http-transport.js
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveToolset } from './toolset.js';
|
|
|
12
12
|
import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
|
|
13
13
|
import { EventBridge } from './event-bridge.js';
|
|
14
14
|
import { mcpInstanceToken } from './tools/shared.js';
|
|
15
|
+
import { releaseCapacityAtSessionEnd } from './tools/capacity-session.js';
|
|
15
16
|
/** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
|
|
16
17
|
* call builds a short-lived OrbotoClient bound to the request's token so the
|
|
17
18
|
* api scopes every read/write to that identity. */
|
|
@@ -278,6 +279,8 @@ export function createHttpServer({ baseUrl, sessionStore }) {
|
|
|
278
279
|
}
|
|
279
280
|
}
|
|
280
281
|
void store.remove(token, sessionId).catch(() => { });
|
|
282
|
+
if (sessionId)
|
|
283
|
+
await releaseCapacityAtSessionEnd(existing?.client ?? new OrbotoClient({ baseUrl, apiKey: token }), mcpInstanceToken(undefined, { sessionId }));
|
|
281
284
|
if (existing) {
|
|
282
285
|
await existing.transport.handleRequest(req, res);
|
|
283
286
|
return;
|
|
@@ -130,6 +130,7 @@ function fakeApi(opts = {}) {
|
|
|
130
130
|
mcpUseGranted: opts.mcpUseGranted ?? true,
|
|
131
131
|
userMcpEnabled: opts.userMcpEnabled ?? true,
|
|
132
132
|
seenAuth: [],
|
|
133
|
+
capacityReleased: [],
|
|
133
134
|
};
|
|
134
135
|
const api = createNodeServer((req, res) => {
|
|
135
136
|
const url = req.url ?? '';
|
|
@@ -154,6 +155,23 @@ function fakeApi(opts = {}) {
|
|
|
154
155
|
res.end(JSON.stringify({ instructions: '' }));
|
|
155
156
|
return;
|
|
156
157
|
}
|
|
158
|
+
if (url.startsWith('/agents/session/unfinished')) {
|
|
159
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
160
|
+
res.end(JSON.stringify({ sessionId: 'sess-uuid', verdict: 'continue', reason: 'runnable', text: 'continue' }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (url.startsWith('/capacity/claims?')) {
|
|
164
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
165
|
+
res.end(JSON.stringify({ items: [{ id: 'c-granted', resourceName: 'build-host:runner-1', state: 'granted' }, { id: 'c-queued', resourceName: 'unity:mac-1', state: 'queued' }], nextCursor: null }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const release = /^\/capacity\/claims\/([^/]+)\/release$/.exec(url);
|
|
169
|
+
if (release && req.method === 'POST') {
|
|
170
|
+
state.capacityReleased.push({ id: release[1], session: String(req.headers['x-orboto-agent-session'] ?? '') });
|
|
171
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify({ id: release[1], state: 'released' }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
157
175
|
if (url.startsWith('/sse/mcp-events')) {
|
|
158
176
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
159
177
|
res.end();
|
|
@@ -238,6 +256,21 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
|
|
|
238
256
|
await res.text();
|
|
239
257
|
expect(store.resolve).toHaveBeenCalledWith('orb_alice', sid);
|
|
240
258
|
});
|
|
259
|
+
it('ORB-2272 - the client ending its session (DELETE) releases the granted and the queued capacity claim of that session', async () => {
|
|
260
|
+
const store = fakeStore();
|
|
261
|
+
const fake = fakeApi();
|
|
262
|
+
const { base } = await startWithFakeApi(store.store, fake);
|
|
263
|
+
const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_alice' });
|
|
264
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
265
|
+
await initRes.text();
|
|
266
|
+
const del = await fetch(`${base}/mcp`, { method: 'DELETE', headers: { authorization: 'Bearer orb_alice', 'mcp-session-id': sid } });
|
|
267
|
+
await del.text();
|
|
268
|
+
expect(del.status).toBeLessThan(300);
|
|
269
|
+
expect(fake.state.capacityReleased).toEqual([
|
|
270
|
+
{ id: 'c-granted', session: `mcp-${sid}` },
|
|
271
|
+
{ id: 'c-queued', session: `mcp-${sid}` },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
241
274
|
it('auto-adopts an unknown session id under a FRESH id when auth is valid (layer 2)', async () => {
|
|
242
275
|
const store = fakeStore();
|
|
243
276
|
const { base } = await startWithFakeApi(store.store);
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,8 @@ async function main() {
|
|
|
58
58
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
59
59
|
const stdio = new StdioServerTransport();
|
|
60
60
|
await server.connect(stdio);
|
|
61
|
+
const { releaseCapacityOnStdinEnd } = await import('./tools/capacity-session.js');
|
|
62
|
+
const releaseCapacity = releaseCapacityOnStdinEnd(process.stdin, preflightClient, (await import('./tools/shared.js')).mcpProcessInstance());
|
|
61
63
|
if (channel) {
|
|
62
64
|
const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
|
|
63
65
|
const { mcpProcessInstance } = await import('./tools/shared.js');
|
|
@@ -81,7 +83,7 @@ async function main() {
|
|
|
81
83
|
const cleanup = () => { clearInterval(beats); inbox.close(); };
|
|
82
84
|
stdio.onclose = cleanup;
|
|
83
85
|
const { endProbeSessionOnStdinEnd } = await import('./probe-session.js');
|
|
84
|
-
endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code) });
|
|
86
|
+
endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code), settle: releaseCapacity });
|
|
85
87
|
}
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
@@ -45,6 +45,8 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
45
45
|
* write instead of after it (measured curated = 39,679 chars / 11,022
|
|
46
46
|
* tokens, from 39,461 / 10,961 - one boolean parameter, no new tool). The
|
|
47
47
|
* verdicts themselves ride the response, not the manifest.
|
|
48
|
+
* ORB-2338 added no named tool; the transcript rides the escape hatch and
|
|
49
|
+
* the resource.
|
|
48
50
|
*
|
|
49
51
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
50
52
|
*/
|
package/dist/oauth-bootstrap.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
|
-
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { VERSION } from './version.js';
|
|
@@ -83,7 +83,7 @@ export function buildAuthorizeUrl(authorizationEndpoint, params) {
|
|
|
83
83
|
u.searchParams.set('state', params.state);
|
|
84
84
|
return u.toString();
|
|
85
85
|
}
|
|
86
|
-
function parseTokenResponse(json) {
|
|
86
|
+
function parseTokenResponse(json, now = Date.now) {
|
|
87
87
|
const t = json;
|
|
88
88
|
if (!t.access_token)
|
|
89
89
|
throw new Error('OAuth token response missing access_token');
|
|
@@ -91,7 +91,7 @@ function parseTokenResponse(json) {
|
|
|
91
91
|
return {
|
|
92
92
|
accessToken: t.access_token,
|
|
93
93
|
refreshToken: t.refresh_token ?? null,
|
|
94
|
-
expiresAt:
|
|
94
|
+
expiresAt: now() + expiresInMs,
|
|
95
95
|
scope: t.scope ?? BOOTSTRAP_SCOPE,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
@@ -113,7 +113,7 @@ export async function exchangeAuthCode(tokenEndpoint, params, fetchImpl = fetch)
|
|
|
113
113
|
}
|
|
114
114
|
return parseTokenResponse(await res.json());
|
|
115
115
|
}
|
|
116
|
-
export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch) {
|
|
116
|
+
export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch, now = Date.now) {
|
|
117
117
|
const res = await fetchImpl(tokenEndpoint, {
|
|
118
118
|
method: 'POST',
|
|
119
119
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
@@ -127,7 +127,7 @@ export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch) {
|
|
|
127
127
|
const body = await res.text().catch(() => '');
|
|
128
128
|
throw new Error(`OAuth token refresh failed: ${res.status} ${body}`);
|
|
129
129
|
}
|
|
130
|
-
return parseTokenResponse(await res.json());
|
|
130
|
+
return parseTokenResponse(await res.json(), now);
|
|
131
131
|
}
|
|
132
132
|
/** Cache file path. Honours ORBOTO_MCP_TOKEN_CACHE for tests / custom homes. */
|
|
133
133
|
export function tokenCachePath() {
|
|
@@ -149,37 +149,91 @@ export function loadCachedGrant(origin, path = tokenCachePath()) {
|
|
|
149
149
|
const file = readCacheFile(path);
|
|
150
150
|
return file[origin] ?? null;
|
|
151
151
|
}
|
|
152
|
-
|
|
153
|
-
const file = readCacheFile(path);
|
|
154
|
-
file[origin] = grant;
|
|
152
|
+
function writeCacheFile(path, file) {
|
|
155
153
|
mkdirSync(dirname(path), { recursive: true });
|
|
156
|
-
|
|
154
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
155
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
|
|
157
156
|
try {
|
|
158
|
-
chmodSync(
|
|
157
|
+
chmodSync(tmp, 0o600);
|
|
159
158
|
}
|
|
160
159
|
catch { /* best-effort on platforms without chmod */ }
|
|
160
|
+
renameSync(tmp, path);
|
|
161
|
+
}
|
|
162
|
+
export function saveCachedGrant(origin, grant, path = tokenCachePath()) {
|
|
163
|
+
const file = readCacheFile(path);
|
|
164
|
+
file[origin] = grant;
|
|
165
|
+
writeCacheFile(path, file);
|
|
161
166
|
}
|
|
162
167
|
export function clearCachedGrant(origin, path = tokenCachePath()) {
|
|
163
168
|
const file = readCacheFile(path);
|
|
164
169
|
if (!(origin in file))
|
|
165
170
|
return;
|
|
166
171
|
delete file[origin];
|
|
172
|
+
writeCacheFile(path, file);
|
|
173
|
+
}
|
|
174
|
+
export const CACHE_LOCK_STALE_MS = 60_000;
|
|
175
|
+
export const CACHE_LOCK_TIMEOUT_MS = 75_000;
|
|
176
|
+
/** Runs `fn` while holding `<cache>.lock`, so processes sharing one cache never present the same refresh token twice. */
|
|
177
|
+
export async function withCacheLock(path, fn, opts = {}) {
|
|
178
|
+
const lockPath = `${path}.lock`;
|
|
179
|
+
const staleMs = opts.staleMs ?? CACHE_LOCK_STALE_MS;
|
|
180
|
+
const deadline = Date.now() + (opts.timeoutMs ?? CACHE_LOCK_TIMEOUT_MS);
|
|
167
181
|
mkdirSync(dirname(path), { recursive: true });
|
|
168
|
-
|
|
182
|
+
for (;;) {
|
|
183
|
+
try {
|
|
184
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
185
|
+
try {
|
|
186
|
+
writeSync(fd, String(process.pid));
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
if (e.code !== 'EEXIST')
|
|
195
|
+
throw e;
|
|
196
|
+
try {
|
|
197
|
+
if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
|
|
198
|
+
unlinkSync(lockPath);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (Date.now() > deadline)
|
|
206
|
+
throw new Error('Another orboto-mcp process holds the OAuth cache lock.');
|
|
207
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
169
210
|
try {
|
|
170
|
-
|
|
211
|
+
return await fn();
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(lockPath);
|
|
216
|
+
}
|
|
217
|
+
catch { /* already reclaimed as stale */ }
|
|
171
218
|
}
|
|
172
|
-
catch { /* best-effort */ }
|
|
173
219
|
}
|
|
174
220
|
/**
|
|
175
221
|
* Build a token provider around an initial token set + a refresh closure.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
222
|
+
* With a store, a refresh first adopts a newer token set another process
|
|
223
|
+
* persisted and rotates only when no live access token is on disk.
|
|
178
224
|
*/
|
|
179
|
-
export function createTokenProvider(initial, refresh, onRefreshed, now = Date.now) {
|
|
225
|
+
export function createTokenProvider(initial, refresh, onRefreshed, now = Date.now, store) {
|
|
180
226
|
let current = initial;
|
|
181
227
|
let inflight = null;
|
|
182
|
-
|
|
228
|
+
const live = (t) => now() < t.expiresAt - EXPIRY_SKEW_MS;
|
|
229
|
+
async function rotate(forced) {
|
|
230
|
+
const seen = current.accessToken;
|
|
231
|
+
const stored = store?.read();
|
|
232
|
+
if (stored && stored.refreshToken && stored.refreshToken !== current.refreshToken) {
|
|
233
|
+
current = stored;
|
|
234
|
+
if (live(stored) && (!forced || stored.accessToken !== seen))
|
|
235
|
+
return current.accessToken;
|
|
236
|
+
}
|
|
183
237
|
if (!current.refreshToken) {
|
|
184
238
|
throw new Error('OAuth session expired and no refresh token is available - reconnect the client.');
|
|
185
239
|
}
|
|
@@ -188,17 +242,20 @@ export function createTokenProvider(initial, refresh, onRefreshed, now = Date.no
|
|
|
188
242
|
onRefreshed?.(current);
|
|
189
243
|
return current.accessToken;
|
|
190
244
|
}
|
|
245
|
+
function doRefresh(forced) {
|
|
246
|
+
return store ? store.withLock(() => rotate(forced)) : rotate(forced);
|
|
247
|
+
}
|
|
191
248
|
return {
|
|
192
249
|
async getAccessToken() {
|
|
193
|
-
if (
|
|
250
|
+
if (live(current))
|
|
194
251
|
return current.accessToken;
|
|
195
252
|
if (!inflight)
|
|
196
|
-
inflight = doRefresh().finally(() => { inflight = null; });
|
|
253
|
+
inflight = doRefresh(false).finally(() => { inflight = null; });
|
|
197
254
|
return inflight;
|
|
198
255
|
},
|
|
199
256
|
async forceRefresh() {
|
|
200
257
|
if (!inflight)
|
|
201
|
-
inflight = doRefresh().finally(() => { inflight = null; });
|
|
258
|
+
inflight = doRefresh(true).finally(() => { inflight = null; });
|
|
202
259
|
return inflight;
|
|
203
260
|
},
|
|
204
261
|
};
|
|
@@ -324,43 +381,70 @@ export async function runLoopbackAuthorization(opts) {
|
|
|
324
381
|
}
|
|
325
382
|
/**
|
|
326
383
|
* Resolve an OAuth token provider for the stdio proxy. Order:
|
|
327
|
-
* 1. A cached grant
|
|
328
|
-
* 2.
|
|
329
|
-
*
|
|
330
|
-
*
|
|
384
|
+
* 1. A cached grant with a live access token -> used as is, nothing rotates.
|
|
385
|
+
* 2. A cached grant whose refresh token still works -> silent refresh under the cache lock.
|
|
386
|
+
* 3. Otherwise run the interactive browser-assisted loopback flow once.
|
|
387
|
+
* The returned provider re-reads the cache under the lock before every refresh.
|
|
331
388
|
*/
|
|
332
389
|
export async function bootstrapOAuth(opts) {
|
|
333
390
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
334
391
|
const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
|
|
335
392
|
const origin = deriveOrigin(opts.apiBaseUrl);
|
|
336
393
|
const cachePath = opts.cachePath ?? tokenCachePath();
|
|
337
|
-
const
|
|
338
|
-
|
|
394
|
+
const now = opts.now ?? Date.now;
|
|
395
|
+
const providerFor = (clientId, tokenEndpoint, initial) => {
|
|
396
|
+
const store = {
|
|
397
|
+
withLock: (fn) => withCacheLock(cachePath, fn),
|
|
398
|
+
read: () => {
|
|
399
|
+
const g = loadCachedGrant(origin, cachePath);
|
|
400
|
+
if (!g || g.clientId !== clientId)
|
|
401
|
+
return null;
|
|
402
|
+
return { accessToken: g.accessToken ?? '', refreshToken: g.refreshToken, expiresAt: g.expiresAt ?? 0, scope: g.scope };
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
const persist = (tokens) => {
|
|
406
|
+
if (!tokens.refreshToken)
|
|
407
|
+
return;
|
|
339
408
|
saveCachedGrant(origin, {
|
|
340
409
|
clientId, tokenEndpoint, refreshToken: tokens.refreshToken, scope: tokens.scope,
|
|
410
|
+
accessToken: tokens.accessToken, expiresAt: tokens.expiresAt,
|
|
341
411
|
}, cachePath);
|
|
342
|
-
}
|
|
412
|
+
};
|
|
413
|
+
return createTokenProvider(initial, (rt) => refreshTokens(tokenEndpoint, { clientId, refreshToken: rt }, fetchImpl, now), persist, now, store);
|
|
343
414
|
};
|
|
344
415
|
const cached = loadCachedGrant(origin, cachePath);
|
|
345
416
|
if (cached) {
|
|
417
|
+
const provider = providerFor(cached.clientId, cached.tokenEndpoint, {
|
|
418
|
+
accessToken: cached.accessToken ?? '', refreshToken: cached.refreshToken,
|
|
419
|
+
expiresAt: cached.expiresAt ?? 0, scope: cached.scope,
|
|
420
|
+
});
|
|
346
421
|
try {
|
|
347
|
-
|
|
348
|
-
clientId: cached.clientId, refreshToken: cached.refreshToken,
|
|
349
|
-
}, fetchImpl);
|
|
350
|
-
persist(cached.clientId, cached.tokenEndpoint, tokens);
|
|
422
|
+
await provider.getAccessToken();
|
|
351
423
|
log('[orboto-mcp] reconnected via cached OAuth session (no browser needed)');
|
|
352
|
-
return
|
|
424
|
+
return provider;
|
|
353
425
|
}
|
|
354
426
|
catch (e) {
|
|
355
427
|
log(`[orboto-mcp] cached OAuth session no longer valid (${e.message}); re-authorizing`);
|
|
356
|
-
|
|
428
|
+
await withCacheLock(cachePath, async () => {
|
|
429
|
+
const onDisk = loadCachedGrant(origin, cachePath);
|
|
430
|
+
const renewedElsewhere = onDisk?.accessToken && (onDisk.expiresAt ?? 0) - EXPIRY_SKEW_MS > now();
|
|
431
|
+
if (onDisk && !renewedElsewhere)
|
|
432
|
+
clearCachedGrant(origin, cachePath);
|
|
433
|
+
});
|
|
357
434
|
}
|
|
358
435
|
}
|
|
359
436
|
const meta = await discoverAuthServer(origin, fetchImpl);
|
|
360
437
|
const { tokens, clientId } = await runLoopbackAuthorization({
|
|
361
438
|
meta, log, openBrowser: opts.openBrowser, fetchImpl,
|
|
362
439
|
});
|
|
363
|
-
|
|
440
|
+
if (tokens.refreshToken) {
|
|
441
|
+
await withCacheLock(cachePath, async () => {
|
|
442
|
+
saveCachedGrant(origin, {
|
|
443
|
+
clientId, tokenEndpoint: meta.token_endpoint, refreshToken: tokens.refreshToken, scope: tokens.scope,
|
|
444
|
+
accessToken: tokens.accessToken, expiresAt: tokens.expiresAt,
|
|
445
|
+
}, cachePath);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
364
448
|
log('[orboto-mcp] OAuth authorization complete');
|
|
365
|
-
return
|
|
449
|
+
return providerFor(clientId, meta.token_endpoint, tokens);
|
|
366
450
|
}
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* its testable pieces (PKCE, authorize-url, request builders).
|
|
6
6
|
*/
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
-
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import { generatePkce, buildAuthorizeUrl, deriveOrigin, discoverAuthServer, registerLoopbackClient, exchangeAuthCode, refreshTokens, loadCachedGrant, saveCachedGrant, clearCachedGrant, createTokenProvider, bootstrapOAuth, EXPIRY_SKEW_MS, } from './oauth-bootstrap.js';
|
|
12
|
+
import { generatePkce, buildAuthorizeUrl, deriveOrigin, discoverAuthServer, registerLoopbackClient, exchangeAuthCode, refreshTokens, loadCachedGrant, saveCachedGrant, clearCachedGrant, createTokenProvider, bootstrapOAuth, withCacheLock, EXPIRY_SKEW_MS, } from './oauth-bootstrap.js';
|
|
13
13
|
describe('PKCE', () => {
|
|
14
14
|
it('produces an S256 challenge that verifies against the verifier', () => {
|
|
15
15
|
const { verifier, challenge, method } = generatePkce();
|
|
@@ -198,3 +198,119 @@ describe('bootstrapOAuth cached-grant path', () => {
|
|
|
198
198
|
expect(loadCachedGrant('https://x.test', path)).toBeNull();
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
describe('createTokenProvider with a shared store', () => {
|
|
202
|
+
const expired = { accessToken: 'at0', refreshToken: 'rt0', expiresAt: 0, scope: 'mcp' };
|
|
203
|
+
const storeOf = (read) => ({ withLock: (fn) => fn(), read });
|
|
204
|
+
it('adopts a newer live token set persisted after construction instead of rotating', async () => {
|
|
205
|
+
const refresh = vi.fn();
|
|
206
|
+
const p = createTokenProvider(expired, refresh, undefined, () => 1_000, storeOf(() => ({
|
|
207
|
+
accessToken: 'at5', refreshToken: 'rt5', expiresAt: 10_000_000, scope: 'mcp',
|
|
208
|
+
})));
|
|
209
|
+
expect(await p.getAccessToken()).toBe('at5');
|
|
210
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
it('continues from the newer refresh token when the stored access token is expired too', async () => {
|
|
213
|
+
const refresh = vi.fn().mockResolvedValue({ accessToken: 'at6', refreshToken: 'rt6', expiresAt: 10_000_000, scope: 'mcp' });
|
|
214
|
+
const p = createTokenProvider(expired, refresh, undefined, () => 1_000, storeOf(() => ({
|
|
215
|
+
accessToken: 'at5', refreshToken: 'rt5', expiresAt: 0, scope: 'mcp',
|
|
216
|
+
})));
|
|
217
|
+
expect(await p.getAccessToken()).toBe('at6');
|
|
218
|
+
expect(refresh).toHaveBeenCalledWith('rt5');
|
|
219
|
+
});
|
|
220
|
+
it('forceRefresh with the same stored token rotates it, with a different live one adopts it', async () => {
|
|
221
|
+
const live = { accessToken: 'at1', refreshToken: 'rt1', expiresAt: 10_000_000, scope: 'mcp' };
|
|
222
|
+
const refresh = vi.fn().mockResolvedValue({ accessToken: 'at2', refreshToken: 'rt2', expiresAt: 10_000_000, scope: 'mcp' });
|
|
223
|
+
let onDisk = live;
|
|
224
|
+
const p = createTokenProvider(live, refresh, (n) => { onDisk = n; }, () => 1_000, storeOf(() => onDisk));
|
|
225
|
+
expect(await p.forceRefresh()).toBe('at2');
|
|
226
|
+
expect(refresh).toHaveBeenCalledWith('rt1');
|
|
227
|
+
onDisk = { accessToken: 'at9', refreshToken: 'rt9', expiresAt: 10_000_000, scope: 'mcp' };
|
|
228
|
+
expect(await p.forceRefresh()).toBe('at9');
|
|
229
|
+
expect(refresh).toHaveBeenCalledOnce();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('bootstrapOAuth with two processes on one cache', () => {
|
|
233
|
+
let dir;
|
|
234
|
+
let path;
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
|
|
237
|
+
path = join(dir, 'cache.json');
|
|
238
|
+
});
|
|
239
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
240
|
+
function rotatingServer() {
|
|
241
|
+
let live = 'rt0';
|
|
242
|
+
let n = 0;
|
|
243
|
+
const sent = [];
|
|
244
|
+
const fetchImpl = vi.fn(async (_url, init) => {
|
|
245
|
+
const body = JSON.parse(String(init?.body));
|
|
246
|
+
sent.push(body.refresh_token);
|
|
247
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
248
|
+
if (body.refresh_token !== live)
|
|
249
|
+
return new Response('{"error":"invalid_grant"}', { status: 400 });
|
|
250
|
+
n += 1;
|
|
251
|
+
live = `rt${n}`;
|
|
252
|
+
return jsonResponse({ access_token: `at${n}`, refresh_token: live, expires_in: 3600, scope: 'mcp offline_access' });
|
|
253
|
+
});
|
|
254
|
+
return { fetchImpl, sent };
|
|
255
|
+
}
|
|
256
|
+
const seed = () => saveCachedGrant('https://x.test', {
|
|
257
|
+
clientId: 'cid', refreshToken: 'rt0', scope: 'mcp offline_access', tokenEndpoint: 'https://x.test/oauth/token',
|
|
258
|
+
}, path);
|
|
259
|
+
it('two concurrent boots on an expired cache rotate once and share the token', async () => {
|
|
260
|
+
seed();
|
|
261
|
+
const { fetchImpl, sent } = rotatingServer();
|
|
262
|
+
const boot = () => bootstrapOAuth({ apiBaseUrl: 'https://x.test/api', fetchImpl, openBrowser: vi.fn(), cachePath: path, log: () => { } });
|
|
263
|
+
const [a, b] = await Promise.all([boot(), boot()]);
|
|
264
|
+
expect(sent).toEqual(['rt0']);
|
|
265
|
+
expect(await a.getAccessToken()).toBe('at1');
|
|
266
|
+
expect(await b.getAccessToken()).toBe('at1');
|
|
267
|
+
expect(loadCachedGrant('https://x.test', path)).toMatchObject({ refreshToken: 'rt1', accessToken: 'at1' });
|
|
268
|
+
});
|
|
269
|
+
it('a boot with a live cached access token rotates nothing, and the later refresh sends the first process rotated token', async () => {
|
|
270
|
+
seed();
|
|
271
|
+
const { fetchImpl, sent } = rotatingServer();
|
|
272
|
+
let offset = 0;
|
|
273
|
+
const now = () => Date.now() + offset;
|
|
274
|
+
const boot = () => bootstrapOAuth({ apiBaseUrl: 'https://x.test/api', fetchImpl, openBrowser: vi.fn(), cachePath: path, log: () => { }, now });
|
|
275
|
+
const a = await boot();
|
|
276
|
+
const b = await boot();
|
|
277
|
+
expect(sent).toEqual(['rt0']);
|
|
278
|
+
offset = 2 * 3600_000;
|
|
279
|
+
expect(await a.getAccessToken()).toBe('at2');
|
|
280
|
+
expect(await b.getAccessToken()).toBe('at2');
|
|
281
|
+
expect(sent).toEqual(['rt0', 'rt1']);
|
|
282
|
+
offset = 4 * 3600_000;
|
|
283
|
+
expect(await b.forceRefresh()).toBe('at3');
|
|
284
|
+
expect(sent).toEqual(['rt0', 'rt1', 'rt2']);
|
|
285
|
+
expect(await a.getAccessToken()).toBe('at3');
|
|
286
|
+
expect(sent).toHaveLength(3);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
describe('withCacheLock', () => {
|
|
290
|
+
let dir;
|
|
291
|
+
let path;
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
|
|
294
|
+
path = join(dir, 'cache.json');
|
|
295
|
+
});
|
|
296
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
297
|
+
it('serialises holders and removes the lock file afterwards', async () => {
|
|
298
|
+
const order = [];
|
|
299
|
+
const hold = (tag) => withCacheLock(path, async () => {
|
|
300
|
+
order.push(`${tag}+`);
|
|
301
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
302
|
+
order.push(`${tag}-`);
|
|
303
|
+
});
|
|
304
|
+
await Promise.all([hold('a'), hold('b')]);
|
|
305
|
+
expect(order.join(' ')).toMatch(/^(a\+ a- b\+ b-|b\+ b- a\+ a-)$/);
|
|
306
|
+
expect(existsSync(`${path}.lock`)).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
it('reclaims a stale lock and times out on a held one', async () => {
|
|
309
|
+
writeFileSync(`${path}.lock`, '1');
|
|
310
|
+
const old = new Date(Date.now() - 120_000);
|
|
311
|
+
utimesSync(`${path}.lock`, old, old);
|
|
312
|
+
await expect(withCacheLock(path, async () => 'ok')).resolves.toBe('ok');
|
|
313
|
+
writeFileSync(`${path}.lock`, '1');
|
|
314
|
+
await expect(withCacheLock(path, async () => 'x', { timeoutMs: 60 })).rejects.toThrow(/holds the OAuth cache lock/);
|
|
315
|
+
});
|
|
316
|
+
});
|
package/dist/probe-session.js
CHANGED
|
@@ -10,7 +10,9 @@ export function endProbeSessionOnStdinEnd(opts) {
|
|
|
10
10
|
return;
|
|
11
11
|
ended = true;
|
|
12
12
|
opts.cleanup();
|
|
13
|
-
void opts.
|
|
13
|
+
void (opts.settle ?? (() => Promise.resolve()))()
|
|
14
|
+
.catch(() => undefined)
|
|
15
|
+
.then(() => opts.client.post('/v1/agent/end-session', {}, { instanceToken: opts.instanceToken }))
|
|
14
16
|
.catch(() => undefined)
|
|
15
17
|
.finally(() => opts.exit(0));
|
|
16
18
|
};
|
|
@@ -3,13 +3,13 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { endProbeSessionOnStdinEnd, PROBE_SESSION_ENV } from './probe-session.js';
|
|
6
|
-
function harness(env) {
|
|
6
|
+
function harness(env, settle) {
|
|
7
7
|
const stdin = new EventEmitter();
|
|
8
8
|
const calls = [];
|
|
9
9
|
const client = { post: async (path, body, opts) => { calls.push({ path, body, instanceToken: opts?.instanceToken }); return undefined; } };
|
|
10
10
|
const cleanup = vi.fn();
|
|
11
11
|
const exit = vi.fn();
|
|
12
|
-
const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit });
|
|
12
|
+
const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit, ...(settle ? { settle } : {}) });
|
|
13
13
|
return { stdin, calls, cleanup, exit, armed };
|
|
14
14
|
}
|
|
15
15
|
describe('ORB-2263 - probe session end', () => {
|
|
@@ -26,6 +26,15 @@ describe('ORB-2263 - probe session end', () => {
|
|
|
26
26
|
expect(h.cleanup).toHaveBeenCalledTimes(1);
|
|
27
27
|
expect(h.calls).toEqual([{ path: '/v1/agent/end-session', body: {}, instanceToken: 'agent-0123456789ab' }]);
|
|
28
28
|
});
|
|
29
|
+
it('ORB-2272 - releases the session\'s capacity claims before the session row ends, even when that release fails', async () => {
|
|
30
|
+
const order = [];
|
|
31
|
+
const h = harness({ [PROBE_SESSION_ENV]: '1' }, async () => { order.push('capacity'); throw new Error('api down'); });
|
|
32
|
+
const post = h.calls;
|
|
33
|
+
h.stdin.emit('end');
|
|
34
|
+
await vi.waitFor(() => expect(h.exit).toHaveBeenCalledWith(0));
|
|
35
|
+
expect(order).toEqual(['capacity']);
|
|
36
|
+
expect(post.map((c) => c.path)).toEqual(['/v1/agent/end-session']);
|
|
37
|
+
});
|
|
29
38
|
it('leaves a real session alone so a resume keeps its row', () => {
|
|
30
39
|
const h = harness({});
|
|
31
40
|
expect(h.armed).toBe(false);
|
package/dist/resources.js
CHANGED
|
@@ -5,6 +5,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
5
5
|
import { OrbotoApiError } from './orboto-client.js';
|
|
6
6
|
import { loadRequiredRules } from './required-rules.js';
|
|
7
7
|
import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
|
|
8
|
+
import { readTranscriptResource } from './tools/live-transcript.js';
|
|
8
9
|
export function registerOrbotoResources(server, client) {
|
|
9
10
|
server.registerResource('rules', new ResourceTemplate('orboto://rules', { list: undefined }), {
|
|
10
11
|
title: 'Workspace agent rules (complete)',
|
|
@@ -74,6 +75,13 @@ export function registerOrbotoResources(server, client) {
|
|
|
74
75
|
}],
|
|
75
76
|
};
|
|
76
77
|
});
|
|
78
|
+
server.registerResource('live-transcript', new ResourceTemplate('orboto://live/{sessionId}/transcript', { list: undefined }), {
|
|
79
|
+
title: 'Live session transcript',
|
|
80
|
+
description: 'ORB-2338 - the newest lines of a live agent session you may see: prompts, assistant markdown, tool groups, turn ends, usage and context, masked. Cut to 4,000 characters with an explicit note; the rest is paged with `orboto_api_call get /agents/live-sessions/<id>/transcript?afterSeq=N`.',
|
|
81
|
+
mimeType: 'text/markdown',
|
|
82
|
+
}, async (uri, vars) => ({
|
|
83
|
+
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: await readTranscriptResource(client, String(vars.sessionId)) }],
|
|
84
|
+
}));
|
|
77
85
|
server.registerResource('search', new ResourceTemplate('orboto://search/{query}', { list: undefined }), {
|
|
78
86
|
title: 'orboto search',
|
|
79
87
|
description: 'Full-text search across tickets, comments, docs - visibility-filtered.',
|
package/dist/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/ag
|
|
|
28
28
|
import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
|
|
29
29
|
import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
|
|
30
30
|
import { agentWakesToolConfig, makeAgentWakesHandler } from './tools/agent-wakes.js';
|
|
31
|
+
import { agentWhoToolConfig, makeAgentWhoHandler } from './tools/agent-who.js';
|
|
31
32
|
import { sessionCheckToolConfig, makeSessionCheckHandler } from './tools/session-check.js';
|
|
32
33
|
import { knowledgeAskToolConfig, makeKnowledgeAskHandler, knowledgeAddToolConfig, makeKnowledgeAddHandler } from './tools/knowledge.js';
|
|
33
34
|
import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
|
|
@@ -210,6 +211,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
210
211
|
reg('orboto_delete_agent_instruction', deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler(client));
|
|
211
212
|
reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
|
|
212
213
|
reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
|
|
214
|
+
reg('orboto_who', agentWhoToolConfig, makeAgentWhoHandler(client));
|
|
213
215
|
reg('orboto_agent_notify', agentNotifyToolConfig, makeAgentNotifyHandler(client));
|
|
214
216
|
reg('orboto_message_work', agentMessageWorkToolConfig, makeAgentMessageWorkHandler(client));
|
|
215
217
|
reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** ORB-2272, ORB-2274 - an orboto_api_call on a tail route is logged under the tool name it stands in for; leaf module. */
|
|
2
|
+
const SEG = '[^/]+';
|
|
3
|
+
export const TAIL_LABELS = [
|
|
4
|
+
{ method: 'GET', path: new RegExp(`^/capacity/resources(/${SEG}(/queue)?)?$`), label: 'orboto_capacity_list' },
|
|
5
|
+
{ method: 'GET', path: new RegExp(`^/capacity/claims(/${SEG})?$`), label: 'orboto_capacity_list' },
|
|
6
|
+
{ method: 'POST', path: /^\/capacity\/claims$/, label: 'orboto_capacity_claim' },
|
|
7
|
+
{ method: 'POST', path: new RegExp(`^/capacity/claims/${SEG}/renew$`), label: 'orboto_capacity_renew' },
|
|
8
|
+
{ method: 'POST', path: new RegExp(`^/capacity/claims/${SEG}/release$`), label: 'orboto_capacity_release' },
|
|
9
|
+
{ method: 'GET', path: /^\/capacity\/windows$/, label: 'orboto_capacity_windows' },
|
|
10
|
+
{ method: 'GET', path: /^\/capacity\/metrics$/, label: 'orboto_capacity_stats' },
|
|
11
|
+
{ method: 'GET', path: new RegExp(`^/capacity/resources/${SEG}/plan$`), label: 'orboto_capacity_plan' },
|
|
12
|
+
{ method: 'POST', path: new RegExp(`^/capacity/resources/${SEG}/plan$`), label: 'orboto_capacity_plan' },
|
|
13
|
+
{ method: 'POST', path: /^\/capacity\/seeds\/nightly-unity$/, label: 'orboto_capacity_seed' },
|
|
14
|
+
];
|
|
15
|
+
/** The metrics label of one tool call: the stand-in tool name for a labelled api_call, else the tool name itself. */
|
|
16
|
+
export function metricsToolName(toolName, args) {
|
|
17
|
+
if (toolName !== 'orboto_api_call' || !args || typeof args !== 'object')
|
|
18
|
+
return toolName;
|
|
19
|
+
const { method, path } = args;
|
|
20
|
+
if (typeof method !== 'string' || typeof path !== 'string')
|
|
21
|
+
return toolName;
|
|
22
|
+
const bare = path.split('?')[0].replace(/\/+$/, '');
|
|
23
|
+
const hit = TAIL_LABELS.find((l) => l.method === method.toUpperCase() && l.path.test(bare));
|
|
24
|
+
return hit ? hit.label : toolName;
|
|
25
|
+
}
|
|
26
|
+
/** The inner HTTP status of an api_call envelope, so a labelled call that the API refused counts as a failure. */
|
|
27
|
+
export function envelopeStatus(structured) {
|
|
28
|
+
const status = structured?.status;
|
|
29
|
+
return typeof status === 'number' ? status : undefined;
|
|
30
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1805 - the schema diet must never cost a PARAMETER.
|
|
3
3
|
*
|
|
4
|
-
* ORB-2157
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Reviewed baseline edits: ORB-2157 made `orboto_help.tool` optional (the
|
|
5
|
+
* tool also answers `topic`; a call with neither lists the topics); ORB-2264
|
|
6
|
+
* made `orboto_agent_notify.targetEmail` optional (`to` names the recipient
|
|
7
|
+
* too, a role address like integrator@KEY reaches several accounts; the
|
|
8
|
+
* parameter stays and narrows a role address to one account).
|
|
8
9
|
*/
|
|
9
10
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
11
|
import { readFileSync } from 'node:fs';
|