@orboto/mcp 0.197.0 → 0.199.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/README.md CHANGED
@@ -104,6 +104,8 @@ Omit `ORBOTO_API_KEY` to use OAuth login instead. `codex mcp add` writes the sam
104
104
  | `ORBOTO_MCP_TOOLSET` | no | `minimal`, `curated` (default) or `full`. Curated is the measured high-frequency set (31 tools, ~7.8k tokens of schemas at connect) - the whole REST API stays reachable via `orboto_api_search` + `orboto_api_call`. `full` registers every named tool (175, ~39.5k tokens). `minimal` is the daily loop only (12 tools, ~2.8k tokens including the instructions block) for local models with an 8k/16k context window. HTTP clients can pick per connection instead: `?toolset=minimal` on the `/mcp` URL or an `x-orboto-toolset: minimal` header. |
105
105
  | `ORBOTO_MCP_PORT` | no | Listen port when transport is `http`. Default `3100`. |
106
106
  | `ORBOTO_MCP_CLIENT` | no | User-agent suffix that lands in the API audit log. Useful for filtering audit rows by client. |
107
+ | `ORBOTO_MCP_CHANNEL` | no | `1` (default) makes the stdio proxy a Claude Code channel: it follows the session's inbox stream and pushes each message as a `notifications/claude/channel` event that wakes the session (`claude --dangerously-load-development-channels server:orboto`, ORB-2140). `0` disables it. |
108
+ | `ORBOTO_MCP_CHANNEL_DIGEST_MINUTES` | no | Digest window for `info` / `complete` inbox mail on the channel. Default `15`; `0` delivers every message at once. Requests, errors, questions and mail older than five minutes always go out immediately. |
107
109
 
108
110
  ## Version compatibility
109
111
 
@@ -0,0 +1,246 @@
1
+ export const CHANNEL_METHOD = 'notifications/claude/channel';
2
+ export const CHANNEL_CAPABILITY = 'claude/channel';
3
+ export const DEFAULT_DIGEST_MINUTES = 15;
4
+ const IMMEDIATE_AFTER_MS = 5 * 60_000;
5
+ const CONTENT_CAP = 2000;
6
+ const SEEN_WINDOW = 1000;
7
+ const MIN_BACKOFF_MS = 2_000;
8
+ const MAX_BACKOFF_MS = 60_000;
9
+ const HEARTBEAT_GRACE_MS = 90_000;
10
+ const ASK = /\?|\bplease\b|\bbitte\b|\bneed\b|\bshare\b/i;
11
+ /** The instructions paragraph Claude Code delivers on connect when the channel is active. */
12
+ export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session arrive as <channel source="orboto" id="..." kind="..." ...> events - '
13
+ + 'peer requests, ticket-ready notices, replies, digests of info/complete mail. Treat each like an inbox message under the rules: '
14
+ + 'a ticket-ready or a request inside your scope is the operator\'s instruction, act on it; read the full message with orboto_messages when the event is cut; '
15
+ + 'reply with orboto_agent_notify (toSessionRef = the sender\'s instance short id in the from attribute) and acknowledge with orboto_messages { ackIds } once handled. '
16
+ + 'Never answer the channel itself and never ack what you did not handle.';
17
+ function firstLine(payload) {
18
+ if (!payload)
19
+ return '';
20
+ for (const key of ['message', 'body', 'text']) {
21
+ const v = payload[key];
22
+ if (typeof v === 'string' && v.trim())
23
+ return v.trim();
24
+ }
25
+ return '';
26
+ }
27
+ function waitedMinutes(createdAt, now) {
28
+ const t = Date.parse(createdAt.replace(' ', 'T').replace(/\+00$/, '+00:00'));
29
+ if (Number.isNaN(t))
30
+ return 0;
31
+ return Math.max(0, Math.floor((now - t) / 60_000));
32
+ }
33
+ /** A message goes out at once when it asks for something or already waited; the rest is digest material. */
34
+ export function isImmediate(m, now, digestMinutes) {
35
+ if (digestMinutes <= 0)
36
+ return true;
37
+ if (m.kind === 'request' || m.kind === 'error')
38
+ return true;
39
+ if (waitedMinutes(m.createdAt, now) * 60_000 >= IMMEDIATE_AFTER_MS)
40
+ return true;
41
+ return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
42
+ }
43
+ export function renderEvent(m, now) {
44
+ const from = m.from?.label ?? m.fromUserId ?? 'unknown';
45
+ const to = m.to?.label ?? 'you';
46
+ const text = firstLine(m.payload);
47
+ const body = text.length > CONTENT_CAP ? `${text.slice(0, CONTENT_CAP)} [... cut, read the rest with orboto_messages]` : text;
48
+ const lines = [
49
+ `[${m.kind}] ${m.subject}`,
50
+ `from ${from} to ${to}${m.projectKey ? ` | project ${m.projectKey}` : ''} | id ${m.id}${m.threadId ? ` | thread ${m.threadId}` : ''}`,
51
+ ];
52
+ if (body)
53
+ lines.push(body);
54
+ const waited = waitedMinutes(m.createdAt, now);
55
+ return {
56
+ content: lines.join('\n'),
57
+ meta: {
58
+ id: m.id, kind: m.kind, project: m.projectKey ?? '',
59
+ from: from, from_session: (m.from?.sessionId ?? '').slice(0, 8), to_session: (m.to?.sessionId ?? '').slice(0, 8),
60
+ waited_min: String(waited),
61
+ },
62
+ };
63
+ }
64
+ export function renderDigest(batch, now) {
65
+ const lines = [`Inbox digest: ${batch.length} info/complete message(s) since the last event. Ack the ones you handled with orboto_messages { ackIds }.`];
66
+ for (const m of batch) {
67
+ const text = firstLine(m.payload);
68
+ lines.push(`- [${m.kind}] ${m.subject} | from ${m.from?.label ?? m.fromUserId ?? 'unknown'}${m.projectKey ? ` | ${m.projectKey}` : ''} | id ${m.id}${text ? ` | ${text.slice(0, 160)}` : ''}`);
69
+ }
70
+ return { content: lines.join('\n'), meta: { kind: 'digest', count: String(batch.length), waited_min: String(waitedMinutes(batch[0].createdAt, now)) } };
71
+ }
72
+ export class InboxChannel {
73
+ opts;
74
+ log;
75
+ now;
76
+ digestMinutes;
77
+ abort = new AbortController();
78
+ closed = false;
79
+ retryTimer = null;
80
+ digestTimer = null;
81
+ backoff = MIN_BACKOFF_MS;
82
+ since;
83
+ lastId = null;
84
+ seen = new Set();
85
+ seenOrder = [];
86
+ batch = [];
87
+ /** Counters an operator can read from the log; tests read them directly. */
88
+ stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0 };
89
+ constructor(opts) {
90
+ this.opts = opts;
91
+ this.log = opts.log ?? ((msg) => { try {
92
+ process.stderr.write(`[orboto-mcp-channel] ${msg}\n`);
93
+ }
94
+ catch { /* ignore */ } });
95
+ this.now = opts.now ?? (() => Date.now());
96
+ this.digestMinutes = opts.digestMinutes ?? DEFAULT_DIGEST_MINUTES;
97
+ this.since = opts.since ?? new Date(this.now()).toISOString();
98
+ }
99
+ start() {
100
+ void this.loop();
101
+ }
102
+ close() {
103
+ this.closed = true;
104
+ if (this.retryTimer) {
105
+ clearTimeout(this.retryTimer);
106
+ this.retryTimer = null;
107
+ }
108
+ if (this.digestTimer) {
109
+ clearTimeout(this.digestTimer);
110
+ this.digestTimer = null;
111
+ }
112
+ try {
113
+ this.abort.abort();
114
+ }
115
+ catch { /* ignore */ }
116
+ }
117
+ /** Feed one message as the stream would; exported for tests and for the digest policy. */
118
+ async deliver(m) {
119
+ if (!m?.id)
120
+ return;
121
+ if (this.seen.has(m.id)) {
122
+ this.stats.duplicates += 1;
123
+ return;
124
+ }
125
+ this.remember(m.id);
126
+ this.lastId = m.id;
127
+ if (isImmediate(m, this.now(), this.digestMinutes)) {
128
+ await this.emit(renderEvent(m, this.now()));
129
+ this.stats.delivered += 1;
130
+ return;
131
+ }
132
+ this.batch.push(m);
133
+ this.stats.digested += 1;
134
+ if (!this.digestTimer) {
135
+ this.digestTimer = setTimeout(() => { this.digestTimer = null; void this.flushDigest(); }, this.digestMinutes * 60_000);
136
+ }
137
+ }
138
+ async flushDigest() {
139
+ if (this.batch.length === 0)
140
+ return;
141
+ const batch = this.batch.splice(0);
142
+ await this.emit(renderDigest(batch, this.now()));
143
+ }
144
+ remember(id) {
145
+ this.seen.add(id);
146
+ this.seenOrder.push(id);
147
+ if (this.seenOrder.length > SEEN_WINDOW) {
148
+ const gone = this.seenOrder.shift();
149
+ if (gone)
150
+ this.seen.delete(gone);
151
+ }
152
+ }
153
+ async emit(event) {
154
+ try {
155
+ await this.opts.mcp.server.notification({ method: CHANNEL_METHOD, params: { content: event.content, meta: event.meta } });
156
+ this.log(`channel event → ${event.meta.kind} ${event.meta.id ?? ''}`.trim());
157
+ }
158
+ catch (err) {
159
+ this.log(`channel notification failed: ${err.message}`);
160
+ }
161
+ }
162
+ async loop() {
163
+ while (!this.closed) {
164
+ try {
165
+ await this.consume();
166
+ this.backoff = MIN_BACKOFF_MS;
167
+ }
168
+ catch (err) {
169
+ if (this.closed)
170
+ return;
171
+ this.log(`inbox stream closed: ${err instanceof Error ? err.message : String(err)} - retrying in ${this.backoff}ms`);
172
+ }
173
+ if (this.closed)
174
+ return;
175
+ this.stats.reconnects += 1;
176
+ await new Promise((resolve) => { this.retryTimer = setTimeout(resolve, this.backoff); });
177
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
178
+ }
179
+ }
180
+ async consume() {
181
+ const fetchFn = this.opts.fetchFn ?? fetch;
182
+ this.abort = new AbortController();
183
+ const since = this.lastId ?? this.since;
184
+ const url = `${this.opts.baseUrl.replace(/\/$/, '')}/v1/agent/messages/stream?since=${encodeURIComponent(since)}`;
185
+ const bearer = this.opts.tokenProvider ? await this.opts.tokenProvider.getAccessToken() : (this.opts.apiKey ?? '');
186
+ const res = await fetchFn(url, {
187
+ method: 'GET',
188
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'text/event-stream', 'x-orboto-agent-session': this.opts.instanceToken },
189
+ signal: this.abort.signal,
190
+ });
191
+ if (!res.ok || !res.body)
192
+ throw new Error(`inbox stream handshake failed (status=${res.status})`);
193
+ this.log(`inbox channel connected (since ${since})`);
194
+ const reader = res.body.getReader();
195
+ const decoder = new TextDecoder();
196
+ let buf = '';
197
+ let lastFrame = this.now();
198
+ const heartbeat = setInterval(() => {
199
+ if (this.now() - lastFrame > HEARTBEAT_GRACE_MS) {
200
+ try {
201
+ this.abort.abort();
202
+ }
203
+ catch { /* ignore */ }
204
+ }
205
+ }, 30_000);
206
+ try {
207
+ for (;;) {
208
+ const { done, value } = await reader.read();
209
+ if (done)
210
+ break;
211
+ lastFrame = this.now();
212
+ buf += decoder.decode(value, { stream: true });
213
+ let idx;
214
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
215
+ const frame = buf.slice(0, idx);
216
+ buf = buf.slice(idx + 2);
217
+ const line = frame.split('\n').find((l) => l.startsWith('data: '));
218
+ if (!line)
219
+ continue;
220
+ let message;
221
+ try {
222
+ message = JSON.parse(line.slice(6));
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ await this.deliver(message);
228
+ }
229
+ }
230
+ }
231
+ finally {
232
+ clearInterval(heartbeat);
233
+ try {
234
+ reader.releaseLock();
235
+ }
236
+ catch { /* ignore */ }
237
+ }
238
+ }
239
+ }
240
+ /** ORB-2140 - the digest window from the environment; NaN or negative fall back to the default. */
241
+ export function digestMinutesFromEnv(raw) {
242
+ if (raw === undefined || raw.trim() === '')
243
+ return DEFAULT_DIGEST_MINUTES;
244
+ const n = Number(raw);
245
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_DIGEST_MINUTES;
246
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ORB-2140 - the inbox channel: stream messages become channel notifications,
3
+ * digest policy, dedupe across reconnects, and the capability declaration.
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
+ import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, InboxChannel, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
9
+ import { buildOrbotoMcpServer } from './server.js';
10
+ function mockMcp() {
11
+ const notification = vi.fn().mockResolvedValue(undefined);
12
+ return { mcp: { server: { notification } }, notification };
13
+ }
14
+ function sse(frames) {
15
+ const encoder = new TextEncoder();
16
+ const stream = new ReadableStream({
17
+ start(controller) {
18
+ for (const frame of frames)
19
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
20
+ controller.close();
21
+ },
22
+ });
23
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });
24
+ }
25
+ const NOW = Date.parse('2026-09-18T10:00:00Z');
26
+ const msg = (over = {}) => ({
27
+ id: 'm1', fromUserId: 'u-spec', kind: 'request', subject: 'ticket-ready:ORB-1', payload: { message: 'please take it' },
28
+ threadId: null, projectKey: 'ORB', createdAt: '2026-09-18 09:59:30+00',
29
+ from: { email: 'spec@orboto.io', sessionId: 'aaaaaaaa-0000-4000-8000-000000000000', role: 'spec', label: 'spec@orboto.io (spec, aaaaaaaa)' },
30
+ to: { email: 'claude@orboto.io', sessionId: 'bbbbbbbb-0000-4000-8000-000000000000', role: 'integrator', label: 'claude@orboto.io (integrator, bbbbbbbb)' },
31
+ ...over,
32
+ });
33
+ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); });
34
+ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
35
+ describe('renderEvent / isImmediate', () => {
36
+ it('names both parties, carries id, project and thread, and caps the body', () => {
37
+ const e = renderEvent(msg({ threadId: 't1', payload: { message: 'x'.repeat(2500) } }), NOW);
38
+ expect(e.content.split('\n')[0]).toBe('[request] ticket-ready:ORB-1');
39
+ expect(e.content.split('\n')[1]).toBe('from spec@orboto.io (spec, aaaaaaaa) to claude@orboto.io (integrator, bbbbbbbb) | project ORB | id m1 | thread t1');
40
+ expect(e.content).toContain('[... cut, read the rest with orboto_messages]');
41
+ expect(e.meta).toMatchObject({ id: 'm1', kind: 'request', project: 'ORB', from_session: 'aaaaaaaa', to_session: 'bbbbbbbb', waited_min: '0' });
42
+ });
43
+ it('request, error, a question and a message that waited over five minutes go out at once; plain info is digest material', () => {
44
+ expect(isImmediate(msg({ kind: 'info', payload: { message: 'pushed abc' } }), NOW, 15)).toBe(false);
45
+ expect(isImmediate(msg({ kind: 'request' }), NOW, 15)).toBe(true);
46
+ expect(isImmediate(msg({ kind: 'error', payload: null }), NOW, 15)).toBe(true);
47
+ expect(isImmediate(msg({ kind: 'info', payload: { message: 'can you check?' } }), NOW, 15)).toBe(true);
48
+ expect(isImmediate(msg({ kind: 'info', payload: { message: 'old' }, createdAt: '2026-09-18 09:50:00+00' }), NOW, 15)).toBe(true);
49
+ expect(isImmediate(msg({ kind: 'info', payload: { message: 'pushed abc' } }), NOW, 0)).toBe(true);
50
+ });
51
+ it('reads the digest window from the environment with a safe default', () => {
52
+ expect(digestMinutesFromEnv(undefined)).toBe(15);
53
+ expect(digestMinutesFromEnv('0')).toBe(0);
54
+ expect(digestMinutesFromEnv('7.9')).toBe(7);
55
+ expect(digestMinutesFromEnv('nope')).toBe(15);
56
+ expect(digestMinutesFromEnv('-3')).toBe(15);
57
+ });
58
+ });
59
+ describe('InboxChannel', () => {
60
+ it('turns each streamed message into one channel notification and batches info into a digest', async () => {
61
+ const { mcp, notification } = mockMcp();
62
+ const fetchFn = vi.fn().mockResolvedValueOnce(sse([
63
+ msg(),
64
+ msg({ id: 'm2', kind: 'info', subject: 'pushed 1', payload: { message: 'ok' }, createdAt: '2026-09-18 09:59:50+00' }),
65
+ msg({ id: 'm3', kind: 'complete', subject: 'done 2', payload: null, createdAt: '2026-09-18 09:59:55+00' }),
66
+ ])).mockImplementation(() => new Promise(() => { }));
67
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 15 });
68
+ channel.start();
69
+ await vi.waitFor(() => expect(notification).toHaveBeenCalledTimes(1));
70
+ const first = notification.mock.calls[0][0];
71
+ expect(first.method).toBe(CHANNEL_METHOD);
72
+ expect(first.params.meta.id).toBe('m1');
73
+ expect(fetchFn.mock.calls[0][1].headers['x-orboto-agent-session']).toBe('mcp-proc');
74
+ expect(String(fetchFn.mock.calls[0][0])).toContain('/v1/agent/messages/stream?since=2026-09-18T10%3A00%3A00.000Z');
75
+ expect(channel.stats).toMatchObject({ delivered: 1, digested: 2 });
76
+ await vi.advanceTimersByTimeAsync(15 * 60_000);
77
+ expect(notification).toHaveBeenCalledTimes(2);
78
+ const digest = notification.mock.calls[1][0];
79
+ expect(digest.params.meta).toMatchObject({ kind: 'digest', count: '2' });
80
+ expect(digest.params.content).toContain('- [info] pushed 1');
81
+ expect(digest.params.content).toContain('- [complete] done 2');
82
+ channel.close();
83
+ });
84
+ it('reconnects from the last delivered id and never delivers a message twice', async () => {
85
+ const { mcp, notification } = mockMcp();
86
+ const fetchFn = vi.fn()
87
+ .mockResolvedValueOnce(sse([msg()]))
88
+ .mockResolvedValueOnce(sse([msg(), msg({ id: 'm9', subject: 'second' })]))
89
+ .mockImplementation(() => new Promise(() => { }));
90
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
91
+ channel.start();
92
+ await vi.waitFor(() => expect(notification).toHaveBeenCalledTimes(1));
93
+ await vi.advanceTimersByTimeAsync(2_000);
94
+ await vi.waitFor(() => expect(notification).toHaveBeenCalledTimes(2));
95
+ expect(String(fetchFn.mock.calls[1][0])).toContain('since=m1');
96
+ expect(channel.stats).toMatchObject({ delivered: 2, duplicates: 1 });
97
+ expect(channel.stats.reconnects).toBeGreaterThanOrEqual(1);
98
+ channel.close();
99
+ });
100
+ });
101
+ describe('server capability', () => {
102
+ async function connect(channel) {
103
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
104
+ const server = await buildOrbotoMcpServer({ baseUrl: 'https://x.test', apiKey: 'orb_k', channel });
105
+ const client = new Client({ name: 't', version: '0' });
106
+ const [a, b] = InMemoryTransport.createLinkedPair();
107
+ await Promise.all([server.connect(a), client.connect(b)]);
108
+ return { server, client };
109
+ }
110
+ it('declares claude/channel and the channel instructions only when asked (stdio)', async () => {
111
+ const on = await connect(true);
112
+ expect(on.client.getServerCapabilities()?.experimental).toHaveProperty(CHANNEL_CAPABILITY);
113
+ expect(on.client.getInstructions()).toContain(CHANNEL_INSTRUCTIONS.slice(0, 40));
114
+ await on.client.close();
115
+ await on.server.close();
116
+ const off = await connect(false);
117
+ expect(off.client.getServerCapabilities()?.experimental ?? {}).not.toHaveProperty(CHANNEL_CAPABILITY);
118
+ expect(off.client.getInstructions() ?? '').not.toContain('Inbox channel:');
119
+ await off.client.close();
120
+ await off.server.close();
121
+ });
122
+ });
package/dist/index.js CHANGED
@@ -53,10 +53,21 @@ async function main() {
53
53
  console.error(`[orboto-mcp] ${err.message}`);
54
54
  process.exit(1);
55
55
  }
56
- const server = await buildOrbotoMcpServer(clientConfig);
56
+ const channel = (process.env.ORBOTO_MCP_CHANNEL ?? '1') !== '0';
57
+ const server = await buildOrbotoMcpServer({ ...clientConfig, channel });
57
58
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
58
59
  const stdio = new StdioServerTransport();
59
60
  await server.connect(stdio);
61
+ if (channel) {
62
+ const { InboxChannel, digestMinutesFromEnv } = await import('./inbox-channel.js');
63
+ const { mcpProcessInstance } = await import('./tools/shared.js');
64
+ const instanceToken = mcpProcessInstance();
65
+ const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
66
+ inbox.start();
67
+ preflightClient.post('/v1/agent/heartbeat', { clientInfo: { channel: 'claude-code' } }, { instanceToken })
68
+ .catch((err) => { console.error(`[orboto-mcp] channel heartbeat failed: ${err.message}`); });
69
+ stdio.onclose = () => { inbox.close(); };
70
+ }
60
71
  return;
61
72
  }
62
73
  if (transport === 'http') {
@@ -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)
package/dist/server.js CHANGED
@@ -15,6 +15,7 @@ import { draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler } from './t
15
15
  import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
16
16
  import { aiUsageToolConfig, makeAiUsageHandler } from './tools/ai-usage.js';
17
17
  import { sessionStartToolConfig, makeSessionStartHandler } from './tools/session-start.js';
18
+ import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS } from './inbox-channel.js';
18
19
  import { loadRequiredRules } from './required-rules.js';
19
20
  import { responseExpandToolConfig, makeResponseExpandHandler } from './tools/response-expand.js';
20
21
  import { helpToolConfig, makeHelpHandler } from './tools/help.js';
@@ -155,18 +156,22 @@ export async function buildOrbotoMcpServer(opts) {
155
156
  catch {
156
157
  workingRules = 'Workspace rules are unavailable. Built-in hints are not a substitute. Call orboto_session_start with forceRules and retry after connectivity or authentication recovers.';
157
158
  }
159
+ const channelHint = opts.channel ? [CHANNEL_INSTRUCTIONS] : [];
158
160
  const server = new McpServer({ name: 'orboto', version: VERSION }, {
159
161
  capabilities: {
160
162
  resources: { subscribe: true, listChanged: true },
163
+ ...(opts.channel ? { experimental: { [CHANNEL_CAPABILITY]: {} } } : {}),
161
164
  },
162
165
  instructions: toolset === 'minimal'
163
166
  ? [
164
167
  staticMcpHints(toolset),
165
168
  'FIRST ACTION: call `orboto_session_start` - it returns the binding workspace rules plus your in-progress work. Re-run it after any context compaction. Non-negotiables: claim or create a ticket before touching code, one commit per ticket with the ticket key in the subject, push after each commit, never mark work done that is not done.',
169
+ ...channelHint,
166
170
  ].join('\n\n')
167
171
  : assembleInstructions([
168
172
  staticMcpHints(toolset),
169
173
  'FIRST ACTION this session: call the `orboto_session_start` tool - it returns the complete, authoritative binding rules you must follow (plus your in-progress work). Re-run it after any context compaction. If the rules below look cut off, `orboto_session_start` and the `orboto://rules` resource always have the full set. (Do NOT use `orboto_list_agent_instructions` to read the rules - that manages rule blocks for admins.) Core non-negotiables: ticket-first (claim or create a ticket before touching code), one commit per ticket with the ticket key in the subject line, push after each commit, and never mark work done that is not actually done.',
174
+ ...channelHint,
170
175
  ].join('\n\n'), workingRules),
171
176
  });
172
177
  const nudgeState = createNudgeState(requireSessionStart);
@@ -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,10 +1,11 @@
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(),
5
6
  });
6
7
  export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
7
- export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web']);
8
+ export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web', 'channel']);
8
9
  export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
9
10
  export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
10
11
  export const AgentInventoryEntrySchema = z.object({
@@ -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,6 +9,7 @@
9
9
  * instead of surfacing as an opaque tool failure.
10
10
  */
11
11
  import { z } from 'zod';
12
+ import { mcpInstanceToken } from './shared.js';
12
13
  export const apiCallToolConfig = {
13
14
  title: 'Call any orboto REST endpoint',
14
15
  description: 'Execute one REST API request - the escape hatch for every endpoint without a named tool. '
@@ -28,7 +29,7 @@ export const apiCallToolConfig = {
28
29
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
29
30
  };
30
31
  export function makeApiCallHandler(client) {
31
- return async (input) => {
32
+ return async (input, extra) => {
32
33
  let body = input.body;
33
34
  if (typeof body === 'string') {
34
35
  try {
@@ -41,7 +42,7 @@ export function makeApiCallHandler(client) {
41
42
  path: input.path,
42
43
  ...(input.query ? { query: input.query } : {}),
43
44
  ...(body !== undefined ? { body } : {}),
44
- });
45
+ }, { instanceToken: mcpInstanceToken(undefined, extra) });
45
46
  const bodyText = envelope.encoding === 'json'
46
47
  ? JSON.stringify(envelope.body, null, 1)
47
48
  : String(envelope.body ?? '');
@@ -100,4 +100,15 @@ describe('orboto_api_call', () => {
100
100
  const res = await makeApiCallHandler(client)({ method: 'GET', path: '/export' });
101
101
  expect(res.content[0].text).toContain('truncated by the proxy cap');
102
102
  });
103
+ it('ORB-2144: the proxy request carries this MCP session as x-orboto-agent-session', async () => {
104
+ const seen = [];
105
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
106
+ seen.push((init?.headers ?? {}));
107
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ status: 200, contentType: 'application/json', body: { sessionId: 's' }, encoding: 'json', truncated: false, matchedRoute: '/v1/agent/heartbeat' }), text: async () => '' };
108
+ });
109
+ await makeApiCallHandler(client)({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'spec' } } }, { sessionId: 'abc123' });
110
+ expect(seen[0]['x-orboto-agent-session']).toBe('mcp-abc123');
111
+ await makeApiCallHandler(client)({ method: 'GET', path: '/v1/agent/messages' });
112
+ expect(seen[1]['x-orboto-agent-session']).toMatch(/^mcp-/);
113
+ });
103
114
  });
@@ -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
+ });
@@ -102,6 +102,10 @@ const MCP_PROCESS_INSTANCE = `mcp-${randomUUID()}`;
102
102
  export function mcpInstanceToken(explicit, extra) {
103
103
  return explicit ?? (extra?.sessionId ? `mcp-${extra.sessionId}` : MCP_PROCESS_INSTANCE);
104
104
  }
105
+ /** ORB-2140 - the stdio process's own instance token, the one the inbox channel follows the stream with. */
106
+ export function mcpProcessInstance() {
107
+ return MCP_PROCESS_INSTANCE;
108
+ }
105
109
  /** ORB-1753 - the caller's self-declared agent profile from the process
106
110
  * environment (stdio servers / runners). The api-key standing profile
107
111
  * (ORB-1751) already covers keys server-side; these env vars let a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.197.0",
3
+ "version": "0.199.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",