@orboto/mcp 0.197.0 → 0.198.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/manifest-size.test.js +8 -4
- package/dist/orboto-client.js +3 -3
- package/dist/tools/agent-coordination.js +16 -9
- package/dist/tools/agent-coordination.test.js +47 -0
- package/dist/tools/agent-messages.js +15 -8
- package/dist/tools/agent-presence-schema.js +5 -0
- package/dist/tools/agent-session-scope.js +8 -0
- package/dist/tools/session-start.js +20 -8
- package/dist/tools/session-start.test.js +44 -0
- package/package.json +1 -1
|
@@ -27,11 +27,15 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
27
27
|
* grow every session's connect cost. ORB-2132 raised the full ceiling by
|
|
28
28
|
* 2,000 chars because the agent_presence output schema now carries kind,
|
|
29
29
|
* actsAs, projects and connections (measured full = 165,759 chars).
|
|
30
|
+
* ORB-2136 raised all three: session_start and agent_heartbeat carry the
|
|
31
|
+
* session scope schema, agent_notify the toSessionRef, and orboto_messages
|
|
32
|
+
* renders sender and receiver identities in its output schema (measured
|
|
33
|
+
* curated = 38,640, full = 168,029 chars, minimal = 3,140 tokens).
|
|
30
34
|
*
|
|
31
35
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
32
36
|
*/
|
|
33
|
-
const CURATED_MAX_CHARS =
|
|
34
|
-
const FULL_MAX_CHARS =
|
|
37
|
+
const CURATED_MAX_CHARS = 39_000;
|
|
38
|
+
const FULL_MAX_CHARS = 169_000;
|
|
35
39
|
/**
|
|
36
40
|
* ORB-1805 - the estimator the ticket measured the failure with
|
|
37
41
|
* (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
|
|
@@ -47,12 +51,12 @@ const estTokens = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
|
47
51
|
* left for the conversation. 3,000 was the ticket's number; 3,100 since
|
|
48
52
|
* ORB-1669 added the annotations block to every tool (+688 chars).
|
|
49
53
|
*/
|
|
50
|
-
const MINIMAL_MAX_TOKENS =
|
|
54
|
+
const MINIMAL_MAX_TOKENS = 3_200;
|
|
51
55
|
/**
|
|
52
56
|
* The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
|
|
53
57
|
* block has its own budget, enforced in instructions-budget.test.ts).
|
|
54
58
|
*/
|
|
55
|
-
const CURATED_SCHEMA_MAX_TOKENS =
|
|
59
|
+
const CURATED_SCHEMA_MAX_TOKENS = 10_800;
|
|
56
60
|
async function measuredManifest(toolset) {
|
|
57
61
|
const server = await buildOrbotoMcpServer({
|
|
58
62
|
baseUrl: 'https://orboto.example.com',
|
package/dist/orboto-client.js
CHANGED
|
@@ -83,11 +83,11 @@ export class OrbotoClient {
|
|
|
83
83
|
const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal, ...(options.instanceToken ? { headers: { 'x-orboto-agent-session': options.instanceToken } } : {}) });
|
|
84
84
|
return (await res.json());
|
|
85
85
|
}
|
|
86
|
-
/** POST a JSON body. */
|
|
87
|
-
async post(path, body) {
|
|
86
|
+
/** POST a JSON body. `instanceToken` sends `x-orboto-agent-session` so the api attributes the call to this MCP session (ORB-2136). */
|
|
87
|
+
async post(path, body, options = {}) {
|
|
88
88
|
const res = await this.authedFetch(this.fullUrl(path), {
|
|
89
89
|
method: 'POST',
|
|
90
|
-
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
headers: { 'Content-Type': 'application/json', ...(options.instanceToken ? { 'x-orboto-agent-session': options.instanceToken } : {}) },
|
|
91
91
|
body: JSON.stringify(body),
|
|
92
92
|
});
|
|
93
93
|
if (res.status === 204)
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
|
|
8
8
|
import { mcpInstanceToken } from './shared.js';
|
|
9
|
+
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
9
10
|
export const agentHeartbeatToolConfig = {
|
|
10
11
|
title: 'Agent heartbeat (Multi-Agent Coordination)',
|
|
11
|
-
description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats.',
|
|
12
|
+
description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it.',
|
|
12
13
|
inputSchema: z.object({
|
|
13
14
|
sessionToken: z.string().nullable().optional(),
|
|
15
|
+
scope: AgentSessionScopeSchema.optional(),
|
|
14
16
|
status: z.enum(['idle', 'working', 'blocked']).optional(),
|
|
15
17
|
workingOnTicketId: z.string().uuid().nullable().optional(),
|
|
16
18
|
capabilities: z.array(z.string()).optional(),
|
|
@@ -24,15 +26,17 @@ export const agentHeartbeatToolConfig = {
|
|
|
24
26
|
outputSchema: z.object({
|
|
25
27
|
sessionToken: z.string(),
|
|
26
28
|
sessionId: z.string().uuid(),
|
|
29
|
+
scope: AgentSessionScopeSchema.nullable(),
|
|
27
30
|
}).shape,
|
|
28
31
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
29
32
|
};
|
|
30
33
|
export function makeAgentHeartbeatHandler(client) {
|
|
31
|
-
return async (args) => {
|
|
32
|
-
const res = await client.post('/v1/agent/heartbeat', args);
|
|
34
|
+
return async (args, extra) => {
|
|
35
|
+
const res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
36
|
+
const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
|
|
33
37
|
return {
|
|
34
|
-
content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}
|
|
35
|
-
structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId },
|
|
38
|
+
content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}… session ${res.sessionId.slice(0, 8)}${scope}` }],
|
|
39
|
+
structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null },
|
|
36
40
|
};
|
|
37
41
|
};
|
|
38
42
|
}
|
|
@@ -80,7 +84,7 @@ export function makeAgentPresenceHandler(client) {
|
|
|
80
84
|
}
|
|
81
85
|
export const agentNotifyToolConfig = {
|
|
82
86
|
title: 'Notify another agent / user',
|
|
83
|
-
description: 'Send a fire-and-forget message to a user (bot or human) by email; it lands in their inbox (orboto_messages) and, for humans, in-app. kind: info | request (answer expected) | complete (sub-task done) | error. payload: free-form JSON; threadId links a reply to the message it answers.',
|
|
87
|
+
description: 'Send a fire-and-forget message to a user (bot or human) by email; it lands in their inbox (orboto_messages) and, for humans, in-app. kind: info | request (answer expected) | complete (sub-task done) | error. payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136).',
|
|
84
88
|
inputSchema: z.object({
|
|
85
89
|
targetEmail: z.string().email(),
|
|
86
90
|
kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
|
|
@@ -89,10 +93,12 @@ export const agentNotifyToolConfig = {
|
|
|
89
93
|
threadId: z.string().uuid().optional(),
|
|
90
94
|
project: z.string().min(1).max(64).optional().describe('Project key or UUID: scope the message to the recipient session working that project.'),
|
|
91
95
|
senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref for self-echo exclusion; defaults to this MCP session.'),
|
|
96
|
+
toSessionRef: z.string().min(1).max(200).optional(),
|
|
92
97
|
}).shape,
|
|
93
98
|
outputSchema: z.object({
|
|
94
99
|
ok: z.literal(true),
|
|
95
100
|
messageId: z.string().uuid(),
|
|
101
|
+
toSessionId: z.string().uuid().nullable(),
|
|
96
102
|
}).shape,
|
|
97
103
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
98
104
|
};
|
|
@@ -123,10 +129,11 @@ export function makeAgentBroadcastHandler(client) {
|
|
|
123
129
|
export function makeAgentNotifyHandler(client) {
|
|
124
130
|
return async (args, extra) => {
|
|
125
131
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
126
|
-
const res = await client.post('/v1/agent/notify', { ...args, senderRef });
|
|
132
|
+
const res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
133
|
+
const target = res.toSessionId ? `${args.targetEmail} session ${res.toSessionId.slice(0, 8)}` : args.targetEmail;
|
|
127
134
|
return {
|
|
128
|
-
content: [{ type: 'text', text: `notified ${
|
|
129
|
-
structuredContent: { ok: true, messageId: res.messageId },
|
|
135
|
+
content: [{ type: 'text', text: `notified ${target} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
|
|
136
|
+
structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null },
|
|
130
137
|
};
|
|
131
138
|
};
|
|
132
139
|
}
|
|
@@ -186,3 +186,50 @@ describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
|
|
|
186
186
|
expect(urls[1]).not.toContain('excludeRef');
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
describe('ORB-2136 - session addressing', () => {
|
|
190
|
+
it('agent_notify forwards toSessionRef and sends this MCP session as x-orboto-agent-session', async () => {
|
|
191
|
+
const seen = [];
|
|
192
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
193
|
+
seen.push({ body: JSON.parse(init?.body ?? '{}'), headers: (init?.headers ?? {}) });
|
|
194
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: 'cbb52195-0000-4000-8000-000000000000' }), text: async () => '' };
|
|
195
|
+
});
|
|
196
|
+
const result = await makeAgentNotifyHandler(client)({ targetEmail: 'bob@example.com', subject: 'hi', toSessionRef: 'cbb52195' }, { sessionId: 'abc123' });
|
|
197
|
+
expect(seen[0].body).toMatchObject({ toSessionRef: 'cbb52195', senderRef: 'mcp-abc123' });
|
|
198
|
+
expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
199
|
+
expect(result.content[0].text).toContain('bob@example.com session cbb52195');
|
|
200
|
+
expect(result.structuredContent).toMatchObject({ toSessionId: 'cbb52195-0000-4000-8000-000000000000' });
|
|
201
|
+
});
|
|
202
|
+
it('orboto_messages identifies this session on the fetch and renders sender and receiver identities', async () => {
|
|
203
|
+
const seen = [];
|
|
204
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
205
|
+
seen.push({ url: String(url), headers: (init?.headers ?? {}) });
|
|
206
|
+
return {
|
|
207
|
+
ok: true, status: 200, statusText: 'OK',
|
|
208
|
+
json: async () => ({ sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{
|
|
209
|
+
id: 'm1', fromUserId: 'u1', toUserId: 'u2', fromSessionId: 's1', toSessionId: 's2',
|
|
210
|
+
from: { userId: 'u1', email: 'spec@orboto.io', sessionId: 's1', role: 'spec', label: 'spec@orboto.io (spec, s1)' },
|
|
211
|
+
to: { userId: 'u2', email: 'claude@orboto.io', sessionId: 's2', role: 'integrator', label: 'claude@orboto.io (integrator, s2)' },
|
|
212
|
+
kind: 'request', subject: 'ticket-ready:ORB-1', payload: null, threadId: null, projectKey: 'ORB', createdAt: 'now', deliveredAt: null, readAt: null,
|
|
213
|
+
}] }),
|
|
214
|
+
text: async () => '',
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
const result = await makeAgentMessagesHandler(client)({}, { sessionId: 'abc123' });
|
|
218
|
+
expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
219
|
+
const text = result.content[0].text;
|
|
220
|
+
expect(text).toContain('from spec@orboto.io (spec, s1) to claude@orboto.io (integrator, s2)');
|
|
221
|
+
expect(text).toContain('This session: cbb52195 (ref mcp-abc123)');
|
|
222
|
+
expect(result.structuredContent).toMatchObject({ sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{ from: { role: 'spec' }, to: { sessionId: 's2' } }] });
|
|
223
|
+
});
|
|
224
|
+
it('agent_heartbeat carries the scope and the session header', async () => {
|
|
225
|
+
const seen = [];
|
|
226
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
227
|
+
seen.push({ body: JSON.parse(init?.body ?? '{}'), headers: (init?.headers ?? {}) });
|
|
228
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ sessionToken: 'mcp-abc123', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } }), text: async () => '' };
|
|
229
|
+
});
|
|
230
|
+
const result = await makeAgentHeartbeatHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
|
|
231
|
+
expect(seen[0].body).toMatchObject({ scope: { role: 'integrator', projectKeys: ['orb'] } });
|
|
232
|
+
expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
233
|
+
expect(result.structuredContent).toMatchObject({ scope: { role: 'integrator', projectKeys: ['ORB'] } });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { makeAgentMessageWorkHandler } from './agent-message-work.js';
|
|
12
12
|
import { mcpInstanceToken } from './shared.js';
|
|
13
|
+
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
13
14
|
export const agentMessagesToolConfig = {
|
|
14
15
|
title: 'Agent inbox and message work',
|
|
15
|
-
description: 'Your inbox. messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. Reply via orboto_agent_notify with threadId.',
|
|
16
|
+
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
|
|
16
17
|
inputSchema: z.object({
|
|
17
18
|
messageWork: z.record(z.unknown()).optional()
|
|
18
19
|
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
|
|
@@ -24,9 +25,12 @@ export const agentMessagesToolConfig = {
|
|
|
24
25
|
}).shape,
|
|
25
26
|
outputSchema: z.object({
|
|
26
27
|
work: z.record(z.unknown()).optional(),
|
|
28
|
+
sessionId: z.string().nullable().optional(),
|
|
27
29
|
messages: z.array(z.object({
|
|
28
30
|
id: z.string(),
|
|
29
31
|
fromUserId: z.string(),
|
|
32
|
+
from: PartySchema,
|
|
33
|
+
to: PartySchema,
|
|
30
34
|
kind: z.string(),
|
|
31
35
|
subject: z.string(),
|
|
32
36
|
payload: z.record(z.string(), z.unknown()).nullable(),
|
|
@@ -59,21 +63,24 @@ export function makeAgentMessagesHandler(client) {
|
|
|
59
63
|
q.set('limit', String(args.limit));
|
|
60
64
|
if (args.project)
|
|
61
65
|
q.set('project', args.project);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const { messages } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}
|
|
66
|
+
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
67
|
+
if (!args.includeOwnSends)
|
|
68
|
+
q.set('excludeRef', instanceToken);
|
|
69
|
+
const { messages, sessionId } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`, { instanceToken });
|
|
66
70
|
const lines = messages.length === 0
|
|
67
71
|
? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
|
|
68
|
-
: messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
|
|
72
|
+
: messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
|
|
69
73
|
if (messages.length > 0) {
|
|
70
|
-
lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId.`);
|
|
74
|
+
lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
|
|
71
75
|
}
|
|
76
|
+
if (sessionId)
|
|
77
|
+
lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
|
|
72
78
|
return {
|
|
73
79
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
74
80
|
structuredContent: {
|
|
81
|
+
sessionId: sessionId ?? null,
|
|
75
82
|
messages: messages.map((m) => ({
|
|
76
|
-
id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject,
|
|
83
|
+
id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
|
|
77
84
|
payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
|
|
78
85
|
createdAt: m.createdAt, readAt: m.readAt,
|
|
79
86
|
})),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
2
3
|
const WorkSessionResourceClaimSchema = z.object({
|
|
3
4
|
kind: z.enum(['path', 'named']), value: z.string().min(1).max(500), mode: z.enum(['read', 'write']),
|
|
4
5
|
state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
|
|
@@ -29,6 +30,10 @@ export const AgentInventoryEntrySchema = z.object({
|
|
|
29
30
|
})),
|
|
30
31
|
capabilities: z.array(z.string()),
|
|
31
32
|
clientInfo: z.record(z.string(), z.string()),
|
|
33
|
+
/** ORB-2136 - the scope the session declared; null when it declared none. */
|
|
34
|
+
scope: AgentSessionScopeSchema.nullable(),
|
|
35
|
+
/** ORB-2136 - unread inbox messages this session would list. */
|
|
36
|
+
unreadMessages: z.number().int(),
|
|
32
37
|
lastSeenAt: z.string(),
|
|
33
38
|
createdAt: z.string(),
|
|
34
39
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** ORB-2136 - standalone mirror of `@orboto/shared-schema` agent-session-scope (the MCP package does not depend on it). */
|
|
3
|
+
export const AgentSessionRoleSchema = z.enum(['integrator', 'lead', 'worker', 'spec', 'review', 'coordinator']);
|
|
4
|
+
export const AgentSessionScopeSchema = z.object({
|
|
5
|
+
projectKeys: z.array(z.string()).optional(),
|
|
6
|
+
ticketKeys: z.array(z.string()).optional(),
|
|
7
|
+
role: AgentSessionRoleSchema.optional(),
|
|
8
|
+
});
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* @see ORB-1605, ORB-1607, ORB-1818
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { resolveTicketByKey, applyAgentProfile } from './shared.js';
|
|
12
|
+
import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken } from './shared.js';
|
|
13
|
+
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
13
14
|
import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
14
15
|
import { loadRequiredRules } from '../required-rules.js';
|
|
15
16
|
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
16
17
|
export const sessionStartToolConfig = {
|
|
17
18
|
title: 'Load the rules you must follow + re-orient',
|
|
18
|
-
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.)
|
|
19
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them.',
|
|
19
|
+
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
|
|
20
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text (nothing else) and is never truncated - the cheapest way to read the rules the index listed. `projectId` adds that project\'s rules on top of the workspace + personal ones. `ticketKey` ("ACME-42") bundles that ticket\'s primer, full detail, dependencies, checklists, git health and other active agent sessions into the same response. `forceRules: true` returns the full rules text inline with the rest of the digest even when this connection already delivered them - use it whenever the rules are NOT in your context right now: after a compaction, a /clear, or a fresh agent taking over an existing connection. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) are your self-declared classification; rule blocks and per-tier rule text are targeted by them. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; the returned session.ref is what peers pass as toSessionRef (ORB-2136).',
|
|
20
21
|
inputSchema: z.object({
|
|
21
22
|
projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
|
|
22
23
|
ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
|
|
@@ -24,8 +25,9 @@ export const sessionStartToolConfig = {
|
|
|
24
25
|
rulesOnly: z.boolean().optional().describe('Return ONLY the complete rules text.'),
|
|
25
26
|
agentKind: z.string().min(1).max(32).optional().describe('coding | orchestrator | reviewer | runner.'),
|
|
26
27
|
modelTier: z.string().min(1).max(32).optional().describe('frontier | standard | small.'),
|
|
28
|
+
scope: AgentSessionScopeSchema.optional(),
|
|
27
29
|
}).shape,
|
|
28
|
-
annotations: { readOnlyHint:
|
|
30
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
29
31
|
};
|
|
30
32
|
const MAX_GIT_HEALTH_PROJECTS = 8;
|
|
31
33
|
/** ORB-1607 - build the `--ticket` one-shot bundle. Never throws: an
|
|
@@ -128,7 +130,8 @@ async function buildTicketBundle(client, ticketKey) {
|
|
|
128
130
|
}
|
|
129
131
|
export function makeSessionStartHandler(client) {
|
|
130
132
|
let lastKnownRulesHash;
|
|
131
|
-
return async (input = {}) => {
|
|
133
|
+
return async (input = {}, extra) => {
|
|
134
|
+
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
132
135
|
const rulesParams = new URLSearchParams();
|
|
133
136
|
if (input.projectId)
|
|
134
137
|
rulesParams.set('projectId', input.projectId);
|
|
@@ -163,16 +166,19 @@ export function makeSessionStartHandler(client) {
|
|
|
163
166
|
},
|
|
164
167
|
};
|
|
165
168
|
}
|
|
169
|
+
const registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
|
|
170
|
+
.then((r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null } : null))
|
|
171
|
+
.catch(() => null);
|
|
166
172
|
const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
|
|
167
173
|
client.get('/users/me').catch(() => null),
|
|
168
174
|
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
169
175
|
client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
|
|
170
176
|
client.get('/time/timer').catch(() => null),
|
|
171
|
-
client.get('/v1/agent/messages?limit=10')
|
|
177
|
+
client.get('/v1/agent/messages?limit=10', { instanceToken })
|
|
172
178
|
.then((r) => (Array.isArray(r?.messages) ? r.messages : []))
|
|
173
179
|
.catch(() => []),
|
|
174
180
|
]);
|
|
175
|
-
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
181
|
+
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
176
182
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
177
183
|
const projectIds = [...new Set(tickets.map((t) => t.projectId).filter((id) => !!id))].slice(0, MAX_GIT_HEALTH_PROJECTS);
|
|
178
184
|
const gitHealthByProject = await Promise.all(projectIds.map(async (projectId) => ({
|
|
@@ -245,10 +251,15 @@ export function makeSessionStartHandler(client) {
|
|
|
245
251
|
}
|
|
246
252
|
lines.push('', '## Timer');
|
|
247
253
|
lines.push(timer?.ticketId ? `Running on ${timer.ticketKey ?? timer.ticketId} since ${timer.startedAt ?? 'earlier'}.` : 'No timer running.');
|
|
254
|
+
lines.push('', '## This session');
|
|
255
|
+
const scopeText = registration?.scope
|
|
256
|
+
? [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('; ')
|
|
257
|
+
: 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it';
|
|
258
|
+
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}" }.`);
|
|
248
259
|
if (pendingMessages.length > 0) {
|
|
249
260
|
lines.push('', '## Agent messages - unread');
|
|
250
261
|
for (const m of pendingMessages)
|
|
251
|
-
lines.push(`- [${m.kind}] ${m.subject} (from ${m.
|
|
262
|
+
lines.push(`- [${m.kind}] ${m.subject} (from ${m.from}, ${m.createdAt}, id ${m.id})`);
|
|
252
263
|
lines.push('Handle them, then acknowledge via orboto_messages { ackIds: [...] }; reply via orboto_agent_notify with threadId.');
|
|
253
264
|
}
|
|
254
265
|
if (bundle)
|
|
@@ -274,6 +285,7 @@ export function makeSessionStartHandler(client) {
|
|
|
274
285
|
})),
|
|
275
286
|
...(elsewhereCount > 0 ? { inProgressElsewhereCount: elsewhereCount } : {}),
|
|
276
287
|
timer: timer?.ticketId ? { ticketKey: timer.ticketKey ?? null, startedAt: timer.startedAt ?? null } : null,
|
|
288
|
+
session: { ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null },
|
|
277
289
|
gitHealth: {
|
|
278
290
|
unhealthy: gitHealthWithConnections
|
|
279
291
|
.map((p) => ({ projectId: p.projectId, connections: p.connections.filter((c) => !c.healthy) }))
|
|
@@ -523,3 +523,47 @@ describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
|
523
523
|
expect(res.content[0].text).toContain(RULES_TEXT);
|
|
524
524
|
});
|
|
525
525
|
});
|
|
526
|
+
describe('ORB-2136 - session scope and ref', () => {
|
|
527
|
+
it('registers the scope on this MCP session via heartbeat with the session header and reports the session ref', async () => {
|
|
528
|
+
const seen = [];
|
|
529
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
530
|
+
const u = new URL(url.toString());
|
|
531
|
+
const path = u.pathname + u.search;
|
|
532
|
+
seen.push({ path, headers: (init?.headers ?? {}), body: init?.body ? JSON.parse(init.body) : undefined });
|
|
533
|
+
const map = {
|
|
534
|
+
'/v1/agent/heartbeat': { sessionToken: 'mcp-abc123', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } },
|
|
535
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
536
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
537
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
538
|
+
'/time/timer': {},
|
|
539
|
+
'/v1/agent/messages': { sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{ id: 'm1', fromUserId: 'u1', from: { label: 'spec@orboto.io (spec, s1)' }, kind: 'request', subject: 'ticket-ready:ORB-1', createdAt: 'now' }] },
|
|
540
|
+
};
|
|
541
|
+
const key = Object.keys(map).find((k) => path.startsWith(k));
|
|
542
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
|
|
543
|
+
});
|
|
544
|
+
const res = await makeSessionStartHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
|
|
545
|
+
const beat = seen.find((c) => c.path === '/v1/agent/heartbeat');
|
|
546
|
+
expect(beat?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
547
|
+
expect(beat?.body).toEqual({ scope: { role: 'integrator', projectKeys: ['orb'] } });
|
|
548
|
+
expect(seen.find((c) => c.path.startsWith('/v1/agent/messages'))?.headers['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
549
|
+
const text = res.content[0].text;
|
|
550
|
+
expect(text).toContain('ref mcp-abc123, instance cbb52195. role integrator; projects ORB');
|
|
551
|
+
expect(text).toContain('toSessionRef: "cbb52195"');
|
|
552
|
+
expect(text).toContain('from spec@orboto.io (spec, s1)');
|
|
553
|
+
expect(res.structuredContent).toMatchObject({ session: { ref: 'mcp-abc123', id: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } } });
|
|
554
|
+
});
|
|
555
|
+
it('without a scope the heartbeat body stays empty and the digest says no scope is declared', async () => {
|
|
556
|
+
const seen = [];
|
|
557
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
558
|
+
const u = new URL(url.toString());
|
|
559
|
+
seen.push({ path: u.pathname, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
560
|
+
const body = u.pathname === '/v1/agent/heartbeat'
|
|
561
|
+
? { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null }
|
|
562
|
+
: u.pathname === '/agent-instructions' ? { instructions: 'rules here', rulesHash: 'fixture' } : {};
|
|
563
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
564
|
+
});
|
|
565
|
+
const res = await makeSessionStartHandler(client)({}, { sessionId: 'abc123' });
|
|
566
|
+
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({});
|
|
567
|
+
expect(res.content[0].text).toContain('no scope declared');
|
|
568
|
+
});
|
|
569
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.198.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",
|