@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.
@@ -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 = 37_500;
34
- const FULL_MAX_CHARS = 167_000;
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 = 3_100;
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 = 10_450;
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',
@@ -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 ${args.targetEmail} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
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
- if (!args.includeOwnSends) {
63
- q.set('excludeRef', mcpInstanceToken(undefined, extra));
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.) Read-only; no side effects. '
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: true, idempotentHint: true },
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.fromUserId}, ${m.createdAt}, id ${m.id})`);
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.197.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",