@orboto/mcp 0.198.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') {
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);
@@ -5,7 +5,7 @@ const WorkSessionResourceClaimSchema = z.object({
5
5
  state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
6
6
  });
7
7
  export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
8
- 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']);
9
9
  export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
10
10
  export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
11
11
  export const AgentInventoryEntrySchema = z.object({
@@ -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
  });
@@ -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.198.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",