@orboto/mcp 0.212.3 → 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.
- package/dist/http-transport-binding.test.js +30 -3
- package/dist/http-transport.js +20 -4
- package/dist/http-transport.test.js +1 -0
- package/dist/oauth-bootstrap.js +18 -3
- package/dist/oauth-browser-opener.test.js +37 -0
- package/dist/response-budget.js +13 -9
- package/dist/response-budget.test.js +17 -0
- package/dist/server.js +5 -3
- package/dist/session-limits.js +39 -0
- package/dist/session-limits.test.js +60 -0
- package/dist/tools/cross-project-links.ee.js +2 -2
- package/dist/tools/cross-project-links.ee.test.js +8 -1
- package/dist/tools/list-git-app-installations.js +4 -2
- package/dist/tools/response-expand.js +3 -3
- package/dist/tools/session-start.js +5 -5
- package/dist/tools/session-start.test.js +2 -2
- package/dist/tools/ticket-writes.js +1 -1
- package/dist/with-metrics.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
+
});
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
}
|
package/dist/oauth-bootstrap.js
CHANGED
|
@@ -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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
});
|
package/dist/response-budget.js
CHANGED
|
@@ -127,15 +127,19 @@ function pruneHandles(now) {
|
|
|
127
127
|
handles.delete(oldest.value);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist/with-metrics.js
CHANGED
|
@@ -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.
|
|
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",
|