@orboto/mcp 0.211.2 → 0.212.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.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-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 +3 -0
- package/dist/tools/session-start.test.js +1 -0
- package/dist/with-metrics.js +9 -5
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** ORB-2272 - build capacity for an MCP-only agent: the escape hatch carries list, claim, renew, release and windows, labelled in the metrics and held to the response budget. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
4
|
+
import { withMetrics } from '../with-metrics.js';
|
|
5
|
+
import { metricsToolName, TAIL_LABELS } from '../tail-labels.js';
|
|
6
|
+
import { makeApiCallHandler } from './api-call.js';
|
|
7
|
+
import { makeHelpHandler } from './help.js';
|
|
8
|
+
const CLAIM = '6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f';
|
|
9
|
+
function mockApi(answer) {
|
|
10
|
+
const instrument = [];
|
|
11
|
+
const proxied = [];
|
|
12
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
13
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
14
|
+
const u = url.toString();
|
|
15
|
+
if (u.endsWith('/admin/mcp/instrument')) {
|
|
16
|
+
instrument.push(body);
|
|
17
|
+
return new Response(JSON.stringify({ id: CLAIM }), { status: 201, headers: { 'content-type': 'application/json' } });
|
|
18
|
+
}
|
|
19
|
+
if (u.endsWith('/system/api-proxy')) {
|
|
20
|
+
proxied.push({ method: body.method, path: body.path, headers: init?.headers });
|
|
21
|
+
const { status, body: inner } = answer(body);
|
|
22
|
+
return new Response(JSON.stringify({ status, contentType: 'application/json', body: inner, encoding: 'json', truncated: false, matchedRoute: `${body.method} ${body.path}` }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
23
|
+
}
|
|
24
|
+
return new Response('not mocked', { status: 500 });
|
|
25
|
+
});
|
|
26
|
+
return { instrument, proxied };
|
|
27
|
+
}
|
|
28
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
29
|
+
const apiCall = withMetrics(client, 'orboto_api_call', undefined, makeApiCallHandler(client));
|
|
30
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
31
|
+
const claimRow = (state) => ({ id: CLAIM, resourceName: 'build-host:runner-1', holderKind: 'agent-session', state, position: state === 'queued' ? 1 : null });
|
|
32
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
33
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
34
|
+
describe('ORB-2272 - capacity through orboto_api_call', () => {
|
|
35
|
+
it('claims, renews and releases as this MCP session and labels each call with its capacity tool', async () => {
|
|
36
|
+
const { instrument, proxied } = mockApi(({ method, path }) => {
|
|
37
|
+
if (method === 'POST' && path === '/capacity/claims')
|
|
38
|
+
return { status: 201, body: { ...claimRow('granted'), holders: [] } };
|
|
39
|
+
if (path.endsWith('/renew'))
|
|
40
|
+
return { status: 200, body: claimRow('granted') };
|
|
41
|
+
if (path.endsWith('/release'))
|
|
42
|
+
return { status: 200, body: claimRow('released') };
|
|
43
|
+
return { status: 200, body: { items: [claimRow('granted')], nextCursor: null } };
|
|
44
|
+
});
|
|
45
|
+
const extra = { sessionId: 'sess-1' };
|
|
46
|
+
const claimed = await apiCall({ method: 'POST', path: '/capacity/claims', body: { resource: 'build-host:runner-1', reason: 'release build', expectedMinutes: 45, priority: 'release' } }, extra);
|
|
47
|
+
expect(claimed.isError).toBeUndefined();
|
|
48
|
+
expect(claimed.structuredContent).toMatchObject({ status: 201, body: { state: 'granted', holderKind: 'agent-session' } });
|
|
49
|
+
await apiCall({ method: 'POST', path: `/capacity/claims/${CLAIM}/renew`, body: {} }, extra);
|
|
50
|
+
await apiCall({ method: 'GET', path: '/capacity/resources/build-host:runner-1' }, extra);
|
|
51
|
+
await apiCall({ method: 'GET', path: '/capacity/windows' }, extra);
|
|
52
|
+
const released = await apiCall({ method: 'POST', path: `/capacity/claims/${CLAIM}/release`, body: {} }, extra);
|
|
53
|
+
expect(released.structuredContent).toMatchObject({ status: 200, body: { state: 'released' } });
|
|
54
|
+
await flush();
|
|
55
|
+
expect(proxied.every((p) => p.headers['x-orboto-agent-session'] === 'mcp-sess-1')).toBe(true);
|
|
56
|
+
expect(instrument.map((e) => [e.toolName, e.success])).toEqual([
|
|
57
|
+
['orboto_capacity_claim', true],
|
|
58
|
+
['orboto_capacity_renew', true],
|
|
59
|
+
['orboto_capacity_list', true],
|
|
60
|
+
['orboto_capacity_windows', true],
|
|
61
|
+
['orboto_capacity_release', true],
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
it('a 403 comes back as data and counts as a failed orboto_capacity_release with its status', async () => {
|
|
65
|
+
const { instrument } = mockApi(() => ({ status: 403, body: { error: 'claim_not_yours', message: 'This claim belongs to another account.' } }));
|
|
66
|
+
const res = await apiCall({ method: 'POST', path: `/capacity/claims/${CLAIM}/release`, body: {} });
|
|
67
|
+
const text = res.content[0].text;
|
|
68
|
+
expect(text).toContain('HTTP 403');
|
|
69
|
+
expect(text).toContain('403 = missing permission');
|
|
70
|
+
expect(res.structuredContent).toMatchObject({ status: 403, body: { error: 'claim_not_yours' } });
|
|
71
|
+
await flush();
|
|
72
|
+
expect(instrument).toEqual([expect.objectContaining({ toolName: 'orboto_capacity_release', success: false, statusCode: 403 })]);
|
|
73
|
+
});
|
|
74
|
+
it('a 404 on an unknown resource comes back as data and counts as a failed orboto_capacity_claim', async () => {
|
|
75
|
+
const { instrument } = mockApi(() => ({ status: 404, body: { error: 'capacity_resource_not_found' } }));
|
|
76
|
+
const res = await apiCall({ method: 'POST', path: '/capacity/claims', body: { resource: 'gpu-farm', reason: 'x', expectedMinutes: 5 } });
|
|
77
|
+
expect(res.content[0].text).toContain('HTTP 404');
|
|
78
|
+
await flush();
|
|
79
|
+
expect(instrument).toEqual([expect.objectContaining({ toolName: 'orboto_capacity_claim', success: false, statusCode: 404 })]);
|
|
80
|
+
});
|
|
81
|
+
it('a long claim list is cut to the api_call budget with an expand handle, never silently', async () => {
|
|
82
|
+
const items = Array.from({ length: 80 }, (_, i) => ({ ...claimRow('queued'), id: `${CLAIM.slice(0, -2)}${String(i).padStart(2, '0')}`, reason: 'nightly unity build '.repeat(6) }));
|
|
83
|
+
const { instrument } = mockApi(() => ({ status: 200, body: { items, nextCursor: 'next' } }));
|
|
84
|
+
const res = await apiCall({ method: 'GET', path: '/capacity/claims', query: { active: true } });
|
|
85
|
+
const text = res.content[0].text;
|
|
86
|
+
expect(text).toContain('orboto_response_expand');
|
|
87
|
+
expect(res.structuredContent.__truncation?.handle).toBeTruthy();
|
|
88
|
+
await flush();
|
|
89
|
+
expect(instrument[0]).toMatchObject({ toolName: 'orboto_capacity_list', success: true });
|
|
90
|
+
expect(Number(instrument[0].truncatedChars)).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('ORB-2272 - metrics labels of the capacity tail', () => {
|
|
94
|
+
it('maps every capacity route an agent calls and leaves everything else as orboto_api_call', () => {
|
|
95
|
+
const cases = [
|
|
96
|
+
['GET', '/capacity/resources', 'orboto_capacity_list'],
|
|
97
|
+
['GET', '/capacity/resources/build-host:runner-1', 'orboto_capacity_list'],
|
|
98
|
+
['GET', '/capacity/resources/build-host:runner-1/queue', 'orboto_capacity_list'],
|
|
99
|
+
['GET', '/capacity/claims', 'orboto_capacity_list'],
|
|
100
|
+
['GET', `/capacity/claims/${CLAIM}`, 'orboto_capacity_list'],
|
|
101
|
+
['post', '/capacity/claims/', 'orboto_capacity_claim'],
|
|
102
|
+
['POST', `/capacity/claims/${CLAIM}/renew`, 'orboto_capacity_renew'],
|
|
103
|
+
['POST', `/capacity/claims/${CLAIM}/release`, 'orboto_capacity_release'],
|
|
104
|
+
['GET', '/capacity/windows', 'orboto_capacity_windows'],
|
|
105
|
+
['GET', '/capacity/resources/build-host:runner-1/plan', 'orboto_capacity_plan'],
|
|
106
|
+
['POST', '/capacity/resources/unity:build/plan?book=true', 'orboto_capacity_plan'],
|
|
107
|
+
['POST', '/capacity/seeds/nightly-unity', 'orboto_capacity_seed'],
|
|
108
|
+
['POST', '/capacity/seeds/other', 'orboto_api_call'],
|
|
109
|
+
['POST', '/capacity/resources', 'orboto_api_call'],
|
|
110
|
+
['POST', `/capacity/claims/${CLAIM}/yield`, 'orboto_api_call'],
|
|
111
|
+
['GET', '/capacity', 'orboto_api_call'],
|
|
112
|
+
['GET', '/projects', 'orboto_api_call'],
|
|
113
|
+
];
|
|
114
|
+
for (const [method, path, label] of cases)
|
|
115
|
+
expect(metricsToolName('orboto_api_call', { method, path }), `${method} ${path}`).toBe(label);
|
|
116
|
+
expect(metricsToolName('orboto_get_ticket', { method: 'POST', path: '/capacity/claims' })).toBe('orboto_get_ticket');
|
|
117
|
+
expect(new Set(TAIL_LABELS.map((l) => l.label))).toEqual(new Set(['orboto_capacity_list', 'orboto_capacity_claim', 'orboto_capacity_renew', 'orboto_capacity_release', 'orboto_capacity_windows', 'orboto_capacity_plan', 'orboto_capacity_seed', 'orboto_capacity_stats']));
|
|
118
|
+
});
|
|
119
|
+
it('ORB-2275 - labels the metrics read as orboto_capacity_stats, with or without a query', () => {
|
|
120
|
+
expect(metricsToolName('orboto_api_call', { method: 'GET', path: '/capacity/metrics' })).toBe('orboto_capacity_stats');
|
|
121
|
+
expect(metricsToolName('orboto_api_call', { method: 'get', path: '/capacity/metrics?resource=build-host:runner-1' })).toBe('orboto_capacity_stats');
|
|
122
|
+
expect(metricsToolName('orboto_api_call', { method: 'POST', path: '/capacity/metrics' })).toBe('orboto_api_call');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('ORB-2275 - capacity metrics through orboto_api_call', () => {
|
|
126
|
+
it('reads the 24-hour and 7-day metrics per resource, a 403 without admin:agents:read comes back as data and counts as a failed orboto_capacity_stats', async () => {
|
|
127
|
+
const window = { grants: 3, waitSecondsMax: 600, holds: 2, expirations: 1, yields: 0, queueDepthMax: 2 };
|
|
128
|
+
let allowed = true;
|
|
129
|
+
const { instrument, proxied } = mockApi(() => (allowed
|
|
130
|
+
? { status: 200, body: { items: [{ resourceName: 'build-host:runner-1', queueDepth: 1, overrunning: 0, last24h: window, last7d: window }], nextCursor: null } }
|
|
131
|
+
: { status: 403, body: { error: 'Forbidden', errorKey: 'errors.permissions.forbidden' } }));
|
|
132
|
+
const ok = await apiCall({ method: 'GET', path: '/capacity/metrics', query: { resource: 'build-host:runner-1' } }, {});
|
|
133
|
+
expect(JSON.stringify(ok.structuredContent)).toContain('"waitSecondsMax":600');
|
|
134
|
+
allowed = false;
|
|
135
|
+
const denied = await apiCall({ method: 'GET', path: '/capacity/metrics' }, {});
|
|
136
|
+
expect(denied.structuredContent).toMatchObject({ status: 403 });
|
|
137
|
+
await flush();
|
|
138
|
+
expect(proxied.map((p) => `${p.method} ${p.path}`)).toEqual(['GET /capacity/metrics', 'GET /capacity/metrics']);
|
|
139
|
+
expect(instrument.map((e) => [e.toolName, e.success])).toEqual([['orboto_capacity_stats', true], ['orboto_capacity_stats', false]]);
|
|
140
|
+
expect(new Set(TAIL_LABELS.map((l) => l.label))).toEqual(new Set(['orboto_capacity_list', 'orboto_capacity_claim', 'orboto_capacity_renew', 'orboto_capacity_release', 'orboto_capacity_windows', 'orboto_capacity_plan', 'orboto_capacity_seed', 'orboto_capacity_stats']));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('ORB-2272 - orboto_help topic capacity', () => {
|
|
144
|
+
it('serves the claim, renew, release, windows and plan recipes', async () => {
|
|
145
|
+
const res = await makeHelpHandler()({ topic: 'capacity' });
|
|
146
|
+
const text = res.content[0].text;
|
|
147
|
+
for (const needle of ['"path":"/capacity/claims"', '/renew', '/release', '/capacity/windows', 'sessionEnd', '?toolset=full', 'ORBOTO_MCP_TOOLSET=full', 'capacity-holds', 'A curated agent has no orboto_session_check', 'session-check --session-end', '"path":"/capacity/metrics"', 'admin:agents:read'])
|
|
148
|
+
expect(text).toContain(needle);
|
|
149
|
+
for (const needle of ['"path":"/capacity/claims"', '/renew', '/release', '/capacity/windows', '/plan', '"book":true', 'graceMinutes', 'capacity plan <resource> --for 45m --book', 'sessionEnd', '?toolset=full', 'ORBOTO_MCP_TOOLSET=full', 'capacity-holds', 'A curated agent has no orboto_session_check', 'session-check --session-end', '/capacity/seeds/nightly-unity', 'capacity wait <claimId> --hold -- <command...>', 'takes no slot'])
|
|
150
|
+
expect(text).toContain(needle);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/tools/docs.js
CHANGED
|
@@ -94,7 +94,7 @@ export function makeGetDocHandler(client) {
|
|
|
94
94
|
}
|
|
95
95
|
export const createDocSpaceToolConfig = {
|
|
96
96
|
title: 'Create a doc space',
|
|
97
|
-
description: 'Create a new wiki space. IMPORTANT: every project ALREADY has an auto-generated general space (slug `<key>-general`, system-generated, holds the AI primer + manual notes) - do NOT create a second space just to hold a project\'s docs. Call orboto_list_doc_spaces first and reuse the existing project space; only create a new one for a genuinely separate collection. `type=global` creates a workspace-wide space (super-admin only); `type=project` creates a space scoped to a project - pass `projectKey` (e.g. "ORB"); a raw `projectId` UUID is still accepted for back-compat. The slug is derived from `name` when omitted. Returns the new space row.',
|
|
97
|
+
description: 'Create a new wiki space. IMPORTANT: every project ALREADY has an auto-generated general space (slug `<key>-general`, system-generated, holds the AI primer + manual notes) - do NOT create a second space just to hold a project\'s docs. Call orboto_list_doc_spaces first and reuse the existing project space; only create a new one for a genuinely separate collection. `type=global` creates a workspace-wide space (super-admin only); `type=project` creates a space scoped to a project - pass `projectKey` (e.g. "ORB"); a raw `projectId` UUID is still accepted for back-compat. The slug is derived from `name` when omitted. A project space needs `project:edit` on the target project (404 otherwise). Returns the new space row.',
|
|
98
98
|
inputSchema: z.object({
|
|
99
99
|
name: z.string().min(1).max(100).describe('Display name.'),
|
|
100
100
|
type: z.enum(['global', 'project']).describe('Scope: workspace-wide or project-scoped.'),
|
|
@@ -199,7 +199,7 @@ export function makeUpdateDocSpaceHandler(client) {
|
|
|
199
199
|
}
|
|
200
200
|
export const deleteDocSpaceToolConfig = {
|
|
201
201
|
title: 'Delete a doc space',
|
|
202
|
-
description: 'DESTRUCTIVE - cascades through every doc in the space (the pages themselves are gone, not just hidden). System-generated project primer spaces refuse deletion (they cascade only when the owning project is deleted). Returns success silently;
|
|
202
|
+
description: 'DESTRUCTIVE - cascades through every doc in the space (the pages themselves are gone, not just hidden). System-generated project primer spaces refuse deletion (they cascade only when the owning project is deleted). Needs `project:edit` on the space\'s project (global spaces: super-admin): 404 for a space the caller cannot see or that does not exist, 403 for one they cannot manage. Returns success silently; errors surface as an OrbotoApiError.',
|
|
203
203
|
inputSchema: z.object({
|
|
204
204
|
spaceId: z.string().uuid(),
|
|
205
205
|
}).shape,
|
|
@@ -377,7 +377,7 @@ export const moveDocToolConfig = {
|
|
|
377
377
|
inputSchema: z.object({
|
|
378
378
|
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
379
379
|
parentDocId: z.string().uuid().nullable().optional().describe('null = top-level; UUID = nest under that doc.'),
|
|
380
|
-
spaceId: z.string().uuid().optional().describe('Move into a different space. Caller must be member of both.'),
|
|
380
|
+
spaceId: z.string().uuid().optional().describe('Move into a different space. Caller must be member of both; the parent must live in the target space, and without parentDocId the doc lands at the top level.'),
|
|
381
381
|
sortOrder: z.number().int().optional(),
|
|
382
382
|
}).shape,
|
|
383
383
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
@@ -409,7 +409,7 @@ export function makeMoveDocHandler(client) {
|
|
|
409
409
|
}
|
|
410
410
|
export const duplicateDocSpaceToolConfig = {
|
|
411
411
|
title: 'Duplicate a doc space (clone the space + the docs you can read)',
|
|
412
|
-
description: 'Fork a doc space with its
|
|
412
|
+
description: 'Fork a doc space along with its doc tree. Needs manage rights on the source space; only docs the caller can read are copied. The new space\'s name becomes `<source> (copy)`, parent-child relationships in the tree are preserved via UUID remap. Useful when an agent wants to iterate on a runbook space without mutating the original.',
|
|
413
413
|
inputSchema: z.object({
|
|
414
414
|
spaceId: z.string().uuid().describe('Source space to duplicate.'),
|
|
415
415
|
}).shape,
|
package/dist/tools/help.js
CHANGED
|
@@ -11,14 +11,15 @@ import { z } from 'zod';
|
|
|
11
11
|
import { getToolDoc, listToolDocNames } from '../tool-docs.js';
|
|
12
12
|
import { connectScriptText } from '../connect-script.js';
|
|
13
13
|
import { onboardingText } from '../agent-head.js';
|
|
14
|
+
import { capacityGuideText } from './capacity-guide.js';
|
|
14
15
|
/** ORB-2157, ORB-2161 - guidance that belongs to no single tool, addressed by topic. */
|
|
15
|
-
const TOPICS = { connect: connectScriptText, onboarding: onboardingText };
|
|
16
|
+
const TOPICS = { connect: connectScriptText, onboarding: onboardingText, capacity: capacityGuideText };
|
|
16
17
|
export const helpToolConfig = {
|
|
17
18
|
title: 'Full guidance for one orboto tool',
|
|
18
|
-
description: 'Return the complete guidance text for one orboto tool by name. Manifest descriptions are one-line summaries; this adds the workflows, warnings and edge cases. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
|
|
19
|
+
description: 'Return the complete guidance text for one orboto tool by name. Manifest descriptions are one-line summaries; this adds the workflows, warnings and edge cases. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop, topic: "capacity" the build capacity recipes.',
|
|
19
20
|
inputSchema: z.object({
|
|
20
21
|
tool: z.string().min(1).max(128).optional().describe('Tool name, e.g. orboto_create_ticket.'),
|
|
21
|
-
topic: z.enum(['connect', 'onboarding']).optional().describe('Guidance topic instead of a tool.'),
|
|
22
|
+
topic: z.enum(['connect', 'onboarding', 'capacity']).optional().describe('Guidance topic instead of a tool.'),
|
|
22
23
|
}).shape,
|
|
23
24
|
outputSchema: z.object({
|
|
24
25
|
tool: z.string(),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Mirrors LIVE_TRANSCRIPT_EVENT_KINDS of @orboto/shared-schema; the MCP package ships without it, apps/api/src/test/agent-live-mcp-drift.test.ts pins the copy. */
|
|
2
|
+
export const LIVE_KINDS = [
|
|
3
|
+
'text', 'tool_call', 'tool_result', 'question', 'permission_prompt', 'limit_prompt', 'login', 'status',
|
|
4
|
+
'user_prompt', 'assistant_text', 'tool_group', 'turn_end', 'usage', 'context', 'checkout',
|
|
5
|
+
];
|
|
6
|
+
/** The cut one rendered line keeps; the full line stays in structuredContent. */
|
|
7
|
+
export const LIVE_LINE_CHARS = 600;
|
|
8
|
+
const USAGE_LABEL = { five_hour: '5h', seven_day: 'weekly', spend_limit: 'spend', other: 'other' };
|
|
9
|
+
const TOOL_VERBS = {
|
|
10
|
+
read: ['read 1 file', 'read {n} files'],
|
|
11
|
+
edit: ['edited 1 file', 'edited {n} files'],
|
|
12
|
+
search: ['ran 1 search', 'ran {n} searches'],
|
|
13
|
+
shell: ['ran 1 shell command', 'ran {n} shell commands'],
|
|
14
|
+
orboto: ['called orboto once', 'called orboto {n} times'],
|
|
15
|
+
web: ['fetched 1 web page', 'fetched {n} web pages'],
|
|
16
|
+
agent: ['started 1 agent', 'started {n} agents'],
|
|
17
|
+
mcp: ['called 1 MCP tool', 'called {n} MCP tools'],
|
|
18
|
+
other: ['used 1 other tool', 'used {n} other tools'],
|
|
19
|
+
};
|
|
20
|
+
function seconds(ms) {
|
|
21
|
+
const total = Math.floor(ms / 1000);
|
|
22
|
+
if (total < 60)
|
|
23
|
+
return `${total}s`;
|
|
24
|
+
const minutes = Math.floor(total / 60);
|
|
25
|
+
return minutes < 60 ? `${minutes}m ${total % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
26
|
+
}
|
|
27
|
+
function cut(text, limit = LIVE_LINE_CHARS) {
|
|
28
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
29
|
+
return flat.length > limit ? `${flat.slice(0, limit)} [cut, ${flat.length - limit} more chars in structuredContent]` : flat;
|
|
30
|
+
}
|
|
31
|
+
/** One line of the transcript as text an agent reads. */
|
|
32
|
+
export function renderLiveEvent(event) {
|
|
33
|
+
switch (event.kind) {
|
|
34
|
+
case 'text': return `${event.role}: ${cut(event.text)}`;
|
|
35
|
+
case 'tool_call': return `-> ${event.name} ${cut(event.input, 200)}`;
|
|
36
|
+
case 'tool_result': return `<- ${event.isError ? 'error' : 'ok'}: ${cut(event.text, 200)}`;
|
|
37
|
+
case 'question': return `? ${cut(event.text)}`;
|
|
38
|
+
case 'permission_prompt': return `! permission ${event.tool}: ${cut(event.text)}`;
|
|
39
|
+
case 'limit_prompt': return `limit: ${cut(event.text)}${event.resetsAt ? ` (resets ${event.resetsAt})` : ''}`;
|
|
40
|
+
case 'login': return `login (${event.harness}): ${event.url}`;
|
|
41
|
+
case 'status': return `[${event.state}${event.detail ? `: ${cut(event.detail, 200)}` : ''}${event.model ? `, ${event.model}` : ''}]`;
|
|
42
|
+
case 'user_prompt': return `> ${cut(event.text)}`;
|
|
43
|
+
case 'assistant_text': return `assistant: ${cut(event.text)}`;
|
|
44
|
+
case 'tool_group': {
|
|
45
|
+
const counts = event.counts.map((c) => (TOOL_VERBS[c.kind] ?? TOOL_VERBS.other)[c.count === 1 ? 0 : 1].replace('{n}', String(c.count))).join(', ') || 'no tool calls';
|
|
46
|
+
const items = event.items.slice(0, 8).map((item) => `\n ${item.name} ${cut(item.summary, 160)}${item.isError ? ' [error]' : ''}`).join('');
|
|
47
|
+
const more = event.items.length > 8 || (event.omittedItems ?? 0) > 0 ? `\n (+${event.items.length - Math.min(8, event.items.length) + (event.omittedItems ?? 0)} more calls)` : '';
|
|
48
|
+
return `* ${counts} (${seconds(event.durationMs)}${event.final ? '' : ', running'})${items}${more}`;
|
|
49
|
+
}
|
|
50
|
+
case 'turn_end': return `-- turn ${event.outcome} after ${seconds(event.durationMs)}${event.model ? `, ${event.model}` : ''}${event.effort ? ` (${event.effort})` : ''}`;
|
|
51
|
+
case 'usage': return `[usage: ${event.windows.map((w) => `${USAGE_LABEL[w.window] ?? w.window} ${Math.round(w.usedPercent)}%${w.resetsAt ? ` (resets ${w.resetsAt})` : ''}`).join(', ')}]`;
|
|
52
|
+
case 'context': return `[context ${Math.round(event.percent)}%${event.tokens !== undefined && event.windowSize !== undefined ? `, ${event.tokens} of ${event.windowSize} tokens` : ''}]`;
|
|
53
|
+
case 'checkout': {
|
|
54
|
+
if (event.repository === 'none')
|
|
55
|
+
return `[checkout: no repository (${event.reason ?? 'unknown'})]`;
|
|
56
|
+
const upstream = event.upstream ? `, upstream ${event.upstream.ref} +${event.upstream.ahead}/-${event.upstream.behind}` : ', no upstream';
|
|
57
|
+
return `[diff +${event.insertions} -${event.deletions}, ${event.filesChanged} files on ${event.branch ?? '?'} against ${event.base ?? 'HEAD'}${upstream}]`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The lines of a page without the running tool group snapshots a later line of the same group replaces. */
|
|
62
|
+
export function latestLines(items) {
|
|
63
|
+
return items.filter((line, index) => line.event.kind !== 'tool_group' || line.event.final
|
|
64
|
+
|| !items.slice(index + 1).some((later) => later.event.kind === 'tool_group' && later.event.groupId === line.event.groupId));
|
|
65
|
+
}
|
|
66
|
+
export function renderTranscriptLines(items) {
|
|
67
|
+
return latestLines(items).map((line) => `${line.seq} ${line.at} ${renderLiveEvent(line.event)}`);
|
|
68
|
+
}
|
|
69
|
+
/** The resource answer's cap; it runs outside the tool budget, so it cuts itself, explicitly. */
|
|
70
|
+
export const LIVE_RESOURCE_CHARS = 4000;
|
|
71
|
+
/** The newest lines that fit the resource cap, oldest first, with an explicit note of what was left out. */
|
|
72
|
+
export function renderTranscriptResource(sessionId, page) {
|
|
73
|
+
const sorted = [...page.items].sort((a, b) => a.seq - b.seq);
|
|
74
|
+
const lines = renderTranscriptLines(sorted);
|
|
75
|
+
const head = `# Live session ${sessionId}\n_lastSeq ${page.lastSeq}; newest lines, masked._\n`;
|
|
76
|
+
const kept = [];
|
|
77
|
+
let size = head.length;
|
|
78
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
79
|
+
if (size + lines[index].length + 1 > LIVE_RESOURCE_CHARS - 200)
|
|
80
|
+
break;
|
|
81
|
+
kept.unshift(lines[index]);
|
|
82
|
+
size += lines[index].length + 1;
|
|
83
|
+
}
|
|
84
|
+
const omitted = lines.length - kept.length;
|
|
85
|
+
const note = omitted > 0 || (sorted[0]?.seq ?? 1) > 1
|
|
86
|
+
? `\n[truncated: earlier lines are not shown here - page the rest with orboto_api_call get /agents/live-sessions/${sessionId}/transcript?afterSeq=N${omitted > 0 ? `; ${omitted} of the newest ${lines.length} did not fit` : ''}]`
|
|
87
|
+
: '';
|
|
88
|
+
return `${head}\n${kept.length ? kept.join('\n') : '_No lines yet._'}${note}`;
|
|
89
|
+
}
|
|
90
|
+
export async function readTranscriptResource(client, sessionId) {
|
|
91
|
+
const page = await client.get(`/agents/live-sessions/${encodeURIComponent(sessionId)}/transcript?order=desc&limit=60`);
|
|
92
|
+
return renderTranscriptResource(sessionId, page);
|
|
93
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { LIVE_KINDS, LIVE_RESOURCE_CHARS, renderLiveEvent, renderTranscriptResource, } from './live-transcript.js';
|
|
3
|
+
const SESSION = '6b2d8e1f-4a3c-4d5e-8f70-112233445566';
|
|
4
|
+
function line(seq, event) {
|
|
5
|
+
return { id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`, sessionId: SESSION, seq, at: '2026-09-30T10:00:00.000Z', maskedCount: 0, event };
|
|
6
|
+
}
|
|
7
|
+
const conversation = [
|
|
8
|
+
{ kind: 'user_prompt', text: 'Wie ist der Stand von ACME-129?' },
|
|
9
|
+
{ kind: 'assistant_text', text: 'Ich schaue mir **ACME-129** an.' },
|
|
10
|
+
{ kind: 'tool_group', groupId: 'g-1', final: false, counts: [{ kind: 'read', count: 1 }], durationMs: 10_000, items: [] },
|
|
11
|
+
{
|
|
12
|
+
kind: 'tool_group', groupId: 'g-1', final: true, durationMs: 22_000,
|
|
13
|
+
counts: [{ kind: 'read', count: 2 }, { kind: 'shell', count: 5 }, { kind: 'orboto', count: 1 }],
|
|
14
|
+
items: [
|
|
15
|
+
{ toolUseId: 't1', name: 'Read', kind: 'read', summary: 'README.md' },
|
|
16
|
+
{ toolUseId: 't2', name: 'Bash', kind: 'shell', summary: 'curl -H \'Authorization: Bearer [secret]\' https://x', isError: true },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{ kind: 'permission_prompt', questionId: 'q1', tool: 'Bash', text: 'Run rm -rf dist?' },
|
|
20
|
+
{ kind: 'turn_end', durationMs: 1_351_000, model: 'claude-opus-5-5', effort: 'high', outcome: 'done' },
|
|
21
|
+
{ kind: 'usage', windows: [{ window: 'five_hour', usedPercent: 36, resetsAt: '2026-09-30T15:00:00Z' }, { window: 'seven_day', usedPercent: 67 }] },
|
|
22
|
+
{ kind: 'context', percent: 51, tokens: 510_000, windowSize: 1_000_000 },
|
|
23
|
+
{ kind: 'checkout', repository: 'git', branch: 'acme-12-preview', base: 'main', upstream: { ref: 'origin/acme-12-preview', ahead: 2, behind: 0 }, filesChanged: 2, insertions: 42, deletions: 7, files: [] },
|
|
24
|
+
{ kind: 'checkout', repository: 'none', reason: 'detached_head', filesChanged: 0, insertions: 0, deletions: 0, files: [] },
|
|
25
|
+
];
|
|
26
|
+
describe('live transcript rendering', () => {
|
|
27
|
+
it('knows every kind the contract names and renders each to one non-empty line head', () => {
|
|
28
|
+
expect(LIVE_KINDS).toHaveLength(15);
|
|
29
|
+
expect(renderLiveEvent(conversation[conversation.length - 2])).toBe('[diff +42 -7, 2 files on acme-12-preview against main, upstream origin/acme-12-preview +2/-0]');
|
|
30
|
+
expect(renderLiveEvent(conversation[conversation.length - 1])).toBe('[checkout: no repository (detached_head)]');
|
|
31
|
+
for (const event of conversation)
|
|
32
|
+
expect(renderLiveEvent(event).length).toBeGreaterThan(3);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('orboto://live/{sessionId}/transcript', () => {
|
|
36
|
+
it('keeps the newest lines within its cap and says what it left out', () => {
|
|
37
|
+
const items = Array.from({ length: 60 }, (_, i) => line(200 - i, { kind: 'assistant_text', text: `Zeile ${200 - i} ${'y'.repeat(300)}` }));
|
|
38
|
+
const text = renderTranscriptResource(SESSION, { items, lastSeq: 200 });
|
|
39
|
+
expect(text.length).toBeLessThanOrEqual(LIVE_RESOURCE_CHARS);
|
|
40
|
+
expect(text).toContain('Zeile 200');
|
|
41
|
+
expect(text).not.toContain('Zeile 141 ');
|
|
42
|
+
expect(text).toMatch(/\[truncated: earlier lines are not shown here - page the rest with orboto_api_call get \/agents\/live-sessions\/[^ ]+\/transcript\?afterSeq=N; \d+ of the newest 60 did not fit\]/);
|
|
43
|
+
});
|
|
44
|
+
it('shows a short session whole', () => {
|
|
45
|
+
const text = renderTranscriptResource(SESSION, { items: conversation.map((event, i) => line(i + 1, event)).reverse(), lastSeq: 8 });
|
|
46
|
+
expect(text).not.toContain('[truncated');
|
|
47
|
+
expect(text.indexOf('> Wie ist')).toBeLessThan(text.indexOf('[context 51%'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
/** ORB-2223 - the completion check: what this session still owes before its turn may end, and the explicit pause. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { mcpInstanceToken, mcpProcessInstance } from './shared.js';
|
|
4
|
+
import { describeCapacityRelease, releaseSessionCapacity } from './capacity-session.js';
|
|
3
5
|
export const sessionCheckToolConfig = {
|
|
4
6
|
title: 'Completion check',
|
|
5
|
-
description: 'Before a turn ends: what this session still owes. verdict continue or may_stop, text says why; it lists, never re-runs. pause with a reason, then resume.',
|
|
7
|
+
description: 'Before a turn ends: what this session still owes. verdict continue or may_stop, text says why; it lists, never re-runs. may_stop releases its granted capacity claims, sessionEnd all. pause with a reason, then resume.',
|
|
6
8
|
inputSchema: z.object({
|
|
7
9
|
action: z.enum(['check', 'pause', 'resume']).default('check'),
|
|
8
10
|
reason: z.string().min(3).max(500).optional(),
|
|
11
|
+
sessionEnd: z.boolean().optional(),
|
|
9
12
|
}).shape,
|
|
10
13
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
11
14
|
};
|
|
12
15
|
export function makeSessionCheckHandler(client) {
|
|
13
|
-
return async (args) => {
|
|
16
|
+
return async (args, extra) => {
|
|
14
17
|
const action = args.action ?? 'check';
|
|
18
|
+
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
15
19
|
if (action === 'pause' && !args.reason) {
|
|
16
20
|
return { isError: true, content: [{ type: 'text', text: 'action pause needs a reason: why the session stops on purpose.' }] };
|
|
17
21
|
}
|
|
18
22
|
if (action !== 'check') {
|
|
19
|
-
const res = await client.post(`/agents/session/${action}`, action === 'pause' ? { reason: args.reason } : {});
|
|
23
|
+
const res = await client.post(`/agents/session/${action}`, action === 'pause' ? { reason: args.reason } : {}, { instanceToken });
|
|
20
24
|
const text = res.pause ? `Paused since ${res.pause.pausedAt}: ${res.pause.reason}` : 'Not paused - the completion check holds runnable work again.';
|
|
21
25
|
return { content: [{ type: 'text', text }], structuredContent: res };
|
|
22
26
|
}
|
|
23
|
-
const res = await client.get('/agents/session/unfinished');
|
|
24
|
-
|
|
27
|
+
const res = await client.get('/agents/session/unfinished', { instanceToken });
|
|
28
|
+
let capacity = null;
|
|
29
|
+
if (args.sessionEnd === true) {
|
|
30
|
+
capacity = await releaseSessionCapacity(client, res.sessionId, 'all', 'agent session ended', instanceToken);
|
|
31
|
+
}
|
|
32
|
+
else if (res.verdict === 'may_stop') {
|
|
33
|
+
capacity = await releaseSessionCapacity(client, res.sessionId, 'granted', 'completion check: may_stop', instanceToken, { respectHolds: instanceToken === mcpProcessInstance() });
|
|
34
|
+
}
|
|
35
|
+
const note = capacity ? describeCapacityRelease(capacity) : '';
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: 'text', text: note ? `${res.text}\n${note}` : res.text }],
|
|
38
|
+
structuredContent: { ...res, ...(capacity ? { capacityReleased: capacity.released, capacityReleaseFailed: capacity.failed } : {}) },
|
|
39
|
+
};
|
|
25
40
|
};
|
|
26
41
|
}
|
|
@@ -8,7 +8,7 @@ describe('orboto_session_check', () => {
|
|
|
8
8
|
const body = { sessionId: 's1', verdict: 'continue', reason: 'runnable', text: 'orboto completion guard: this session still owns runnable authorized work, so the turn does not end yet.' };
|
|
9
9
|
const get = vi.fn().mockResolvedValue(body);
|
|
10
10
|
const result = await makeSessionCheckHandler({ get })({});
|
|
11
|
-
expect(get).toHaveBeenCalledWith('/agents/session/unfinished');
|
|
11
|
+
expect(get).toHaveBeenCalledWith('/agents/session/unfinished', { instanceToken: expect.stringMatching(/^agent-/) });
|
|
12
12
|
expect(textOf(result)).toContain('does not end yet');
|
|
13
13
|
expect(result.structuredContent).toMatchObject({ verdict: 'continue' });
|
|
14
14
|
});
|
|
@@ -19,8 +19,8 @@ describe('orboto_session_check', () => {
|
|
|
19
19
|
const handler = makeSessionCheckHandler({ post });
|
|
20
20
|
expect((await handler({ action: 'pause' })).isError).toBe(true);
|
|
21
21
|
expect(textOf(await handler({ action: 'pause', reason: 'Waiting for the release window' }))).toContain('Waiting for the release window');
|
|
22
|
-
expect(post).toHaveBeenCalledWith('/agents/session/pause', { reason: 'Waiting for the release window' });
|
|
22
|
+
expect(post).toHaveBeenCalledWith('/agents/session/pause', { reason: 'Waiting for the release window' }, { instanceToken: expect.stringMatching(/^agent-/) });
|
|
23
23
|
expect(textOf(await handler({ action: 'resume' }))).toContain('Not paused');
|
|
24
|
-
expect(post).toHaveBeenLastCalledWith('/agents/session/resume', {});
|
|
24
|
+
expect(post).toHaveBeenLastCalledWith('/agents/session/resume', {}, { instanceToken: expect.stringMatching(/^agent-/) });
|
|
25
25
|
});
|
|
26
26
|
});
|
|
@@ -303,6 +303,9 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
303
303
|
? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
|
|
304
304
|
: 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it';
|
|
305
305
|
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}" }.`);
|
|
306
|
+
const roleAddresses = registration?.scope?.role ? (registration.scope.projectKeys ?? []).map((k) => `${registration.scope.role}@${k}`) : [];
|
|
307
|
+
if (roleAddresses.length)
|
|
308
|
+
lines.push(`Address form: peers reach this session and every other ${registration.scope.role} of the project with orboto_agent_notify { to: "${roleAddresses[0]}" } (${roleAddresses.join(', ')}); address a role, never a session id copied from a log.`);
|
|
306
309
|
if (registration?.scope)
|
|
307
310
|
lines.push(identityLine(registration));
|
|
308
311
|
if (opts.channel)
|
|
@@ -552,6 +552,7 @@ describe('ORB-2136 - session scope and ref', () => {
|
|
|
552
552
|
const text = res.content[0].text;
|
|
553
553
|
expect(text).toContain('ref mcp-abc123, instance cbb52195. role integrator; projects ORB');
|
|
554
554
|
expect(text).toContain('toSessionRef: "cbb52195"');
|
|
555
|
+
expect(text).toContain('Address form: peers reach this session and every other integrator of the project with orboto_agent_notify { to: "integrator@ORB" }');
|
|
555
556
|
expect(text).toContain('from spec@orboto.io (spec, s1)');
|
|
556
557
|
expect(res.structuredContent).toMatchObject({ session: { ref: 'mcp-abc123', id: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } } });
|
|
557
558
|
});
|
package/dist/with-metrics.js
CHANGED
|
@@ -5,6 +5,7 @@ import { applyResponseBudget, TruncationBlockAdvertisedSchema } from './response
|
|
|
5
5
|
import { buildStrictInputSchema, isRawShape } from './input-schema.js';
|
|
6
6
|
import { captureToolDoc, summarizeToolDescription } from './tool-docs.js';
|
|
7
7
|
import { postLogEntry, redactSecrets } from './mcp-instrument.js';
|
|
8
|
+
import { envelopeStatus, metricsToolName } from './tail-labels.js';
|
|
8
9
|
/**
|
|
9
10
|
* ORB-1174 - turn an OrbotoApiError into an actionable, agent-visible
|
|
10
11
|
* tool-error message. Before this, a thrown error reached the MCP runtime
|
|
@@ -57,17 +58,20 @@ export function withMetrics(client, toolName, clientHint, handler, nudge) {
|
|
|
57
58
|
});
|
|
58
59
|
return gated;
|
|
59
60
|
}
|
|
61
|
+
const label = metricsToolName(toolName, args);
|
|
60
62
|
try {
|
|
61
63
|
const handlerResult = await handler(args, extra);
|
|
62
64
|
const budgeted = applyResponseBudget(toolName, handlerResult);
|
|
63
65
|
const result = budgeted.result;
|
|
64
|
-
const
|
|
66
|
+
const inner = label === toolName ? undefined : envelopeStatus(handlerResult.structuredContent);
|
|
67
|
+
const isError = result.isError === true || (inner !== undefined && inner >= 400);
|
|
65
68
|
if (nudge)
|
|
66
|
-
recordSessionStartResult(nudge, toolName,
|
|
69
|
+
recordSessionStartResult(nudge, toolName, result.isError !== true);
|
|
67
70
|
void postLogEntry(client, {
|
|
68
|
-
toolName,
|
|
71
|
+
toolName: label,
|
|
69
72
|
durationMs: Date.now() - start,
|
|
70
73
|
success: !isError,
|
|
74
|
+
...(inner !== undefined && inner >= 400 ? { statusCode: inner } : {}),
|
|
71
75
|
errorMessage: isError && result.content[0] && 'text' in result.content[0]
|
|
72
76
|
? redactSecrets(String(result.content[0].text)).slice(0, 500)
|
|
73
77
|
: undefined,
|
|
@@ -88,12 +92,12 @@ export function withMetrics(client, toolName, clientHint, handler, nudge) {
|
|
|
88
92
|
}
|
|
89
93
|
if (err instanceof OrbotoApiError) {
|
|
90
94
|
const text = formatApiError(err);
|
|
91
|
-
void postLogEntry(client, { toolName, durationMs, success: false, statusCode: err.status, errorMessage: redactSecrets(text).slice(0, 500), clientHint });
|
|
95
|
+
void postLogEntry(client, { toolName: label, durationMs, success: false, statusCode: err.status, errorMessage: redactSecrets(text).slice(0, 500), clientHint });
|
|
92
96
|
const errResult = { isError: true, content: [{ type: 'text', text }] };
|
|
93
97
|
return wantsNudge ? prependNudge(errResult) : errResult;
|
|
94
98
|
}
|
|
95
99
|
void postLogEntry(client, {
|
|
96
|
-
toolName,
|
|
100
|
+
toolName: label,
|
|
97
101
|
durationMs,
|
|
98
102
|
success: false,
|
|
99
103
|
errorMessage: redactSecrets(err instanceof Error ? err.message : String(err)).slice(0, 500),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.212.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",
|