@orboto/mcp 0.204.0 → 0.205.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
@@ -119,7 +119,7 @@ Omit `ORBOTO_API_KEY` to use OAuth login instead. `codex mcp add` writes the sam
119
119
  | `ORBOTO_MCP_TOKEN_CACHE` | no | Override the OAuth token cache path (default `~/.config/orboto/mcp-oauth.json`). |
120
120
  | `ORBOTO_MCP_NO_BROWSER` | no | Set to `1` to print the authorization URL instead of auto-opening a browser (headless hosts). |
121
121
  | `ORBOTO_MCP_TRANSPORT` | no | `stdio` (default) or `http`. Stdio is the right choice for `npx`-launched clients. |
122
- | `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. |
122
+ | `ORBOTO_MCP_TOOLSET` | no | `channel`, `minimal`, `curated` (default) or `full`. `channel` registers no tool, prompt or resource: the agent works through the `orboto` CLI, the server carries the Claude Code wake channel and an instructions block under 400 tokens - it is what `orboto mcp install`, `orboto connect` and the Claude Code plugin write. 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. |
123
123
  | `ORBOTO_MCP_PORT` | no | Listen port when transport is `http`. Default `3100`. |
124
124
  | `ORBOTO_MCP_CLIENT` | no | User-agent suffix that lands in the API audit log. Useful for filtering audit rows by client. |
125
125
  | `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. |
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ORB-2210 - the `channel` toolset: no tools, CLI-first instructions, and
3
+ * the wake channel still delivers into the connected session.
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 { z } from 'zod';
9
+ import { buildOrbotoMcpServer } from './server.js';
10
+ import { CHANNEL_CAPABILITY, CHANNEL_METHOD, InboxChannel } from './inbox-channel.js';
11
+ import { resolveToolset, toolInToolset } from './toolset.js';
12
+ beforeEach(() => { delete process.env.ORBOTO_MCP_TOOLSET; });
13
+ afterEach(() => { vi.restoreAllMocks(); delete process.env.ORBOTO_MCP_TOOLSET; });
14
+ const ChannelNotification = z.object({
15
+ method: z.literal(CHANNEL_METHOD),
16
+ params: z.object({ content: z.string(), meta: z.record(z.string()) }),
17
+ });
18
+ function sse(frames) {
19
+ const encoder = new TextEncoder();
20
+ const stream = new ReadableStream({
21
+ start(controller) {
22
+ for (const frame of frames)
23
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
24
+ controller.close();
25
+ },
26
+ });
27
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });
28
+ }
29
+ async function connectChannelServer() {
30
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
31
+ const server = await buildOrbotoMcpServer({ baseUrl: 'https://x.test', apiKey: 'orb_k', channel: true, toolset: 'channel' });
32
+ const client = new Client({ name: 't', version: '0' });
33
+ const [a, b] = InMemoryTransport.createLinkedPair();
34
+ await Promise.all([server.connect(a), client.connect(b)]);
35
+ return { server, client, fetchSpy };
36
+ }
37
+ describe('resolveToolset / toolInToolset - channel', () => {
38
+ it('channel resolves from either surface and admits no tool', () => {
39
+ expect(resolveToolset('channel', undefined)).toBe('channel');
40
+ expect(resolveToolset(undefined, 'channel')).toBe('channel');
41
+ expect(resolveToolset('channel', 'full')).toBe('channel');
42
+ for (const name of ['orboto_session_start', 'orboto_get_ticket', 'orboto_api_call', 'orboto_messages']) {
43
+ expect(toolInToolset(name, 'channel'), name).toBe(false);
44
+ }
45
+ });
46
+ });
47
+ describe('ORB-2210 - the channel toolset', () => {
48
+ it('starts, answers initialize, registers no tool, prompt or resource, and fetches no rules at connect', async () => {
49
+ const { server, client, fetchSpy } = await connectChannelServer();
50
+ expect(client.getServerVersion()?.name).toBe('orboto');
51
+ expect(client.getServerCapabilities()?.tools).toBeUndefined();
52
+ expect(client.getServerCapabilities()?.prompts).toBeUndefined();
53
+ expect(client.getServerCapabilities()?.resources).toBeUndefined();
54
+ expect(client.getServerCapabilities()?.experimental).toHaveProperty(CHANNEL_CAPABILITY);
55
+ expect(fetchSpy).not.toHaveBeenCalled();
56
+ await client.close();
57
+ await server.close();
58
+ });
59
+ it('instructions point at the CLI, the scope command and the way back to the tools', async () => {
60
+ const { server, client } = await connectChannelServer();
61
+ const text = client.getInstructions() ?? '';
62
+ expect(text).toContain('`orboto help`');
63
+ expect(text).toContain('orboto session-start');
64
+ expect(text).toContain('orboto agent-heartbeat --role');
65
+ expect(text).toContain('--scope-projects');
66
+ expect(text).toContain('ORBOTO_MCP_TOOLSET=curated|full');
67
+ expect(text).toContain('orboto messages --ack');
68
+ expect(text).not.toMatch(/orboto_[a-z_]+/);
69
+ await client.close();
70
+ await server.close();
71
+ });
72
+ it('a streamed inbox message reaches the connected client as a channel event', async () => {
73
+ const { server, client } = await connectChannelServer();
74
+ const received = [];
75
+ client.setNotificationHandler(ChannelNotification, (n) => { received.push(n.params); });
76
+ const fetchFn = vi.fn().mockResolvedValueOnce(sse([{
77
+ id: 'm1', fromUserId: 'u-spec', kind: 'request', subject: 'ticket-ready:ORB-1', payload: { message: 'please take it' },
78
+ threadId: null, projectKey: 'ORB', createdAt: new Date().toISOString(),
79
+ }])).mockImplementation(() => new Promise(() => { }));
80
+ const inbox = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp: server, fetchFn, log: () => { }, digestMinutes: 15 });
81
+ inbox.start();
82
+ await vi.waitFor(() => expect(received).toHaveLength(1));
83
+ expect(received[0].meta).toMatchObject({ id: 'm1', kind: 'request', project: 'ORB' });
84
+ expect(received[0].content).toContain('ticket-ready:ORB-1');
85
+ expect(inbox.stats.delivered).toBe(1);
86
+ inbox.close();
87
+ await client.close();
88
+ await server.close();
89
+ });
90
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ORB-2157 - the one source of the script an agent follows when a person
3
3
  * asks it to set orboto up. `orboto_help { topic: "connect" }` returns it,
4
- * docs/connect-your-agent.md, docs/claude-code.md and skills/orboto/SKILL.md
4
+ * docs/connect-your-agent.md, docs/claude-code.md and skills/orboto/reference/agents.md
5
5
  * carry the same sentences, and connect-script.test.ts holds them together.
6
6
  */
7
7
  export const CONNECT_SCRIPT_HEADING = 'If you are an AI agent helping a person set this up';
@@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs';
8
8
  import { CONNECT_SCRIPT_HEADING, CONNECT_SCRIPT_STEPS, connectScriptText } from './connect-script.js';
9
9
  import { makeHelpHandler } from './tools/help.js';
10
10
  const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
11
- const CARRIERS = ['docs/connect-your-agent.md', 'docs/claude-code.md', 'skills/orboto/SKILL.md'];
11
+ const CARRIERS = ['docs/connect-your-agent.md', 'docs/claude-code.md', 'skills/orboto/reference/agents.md'];
12
12
  describe('the connect script', () => {
13
13
  it('reads as a numbered script with the pages it links', () => {
14
14
  const text = connectScriptText();
@@ -1,4 +1,5 @@
1
1
  import { RESTART_REQUESTED_NOTICE, writeRestartRequest } from './session-restart.js';
2
+ import { confirmScope, rememberedScope } from './session-scope-memory.js';
2
3
  export { RESTART_REQUESTED_NOTICE };
3
4
  export const CHANNEL_METHOD = 'notifications/claude/channel';
4
5
  export const CHANNEL_CAPABILITY = 'claude/channel';
@@ -19,7 +20,8 @@ export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session ar
19
20
  + 'not_mine for another session\'s mail (the sender is told, and the message never reaches this session again), obsolete or duplicate with a note when the request is already done. '
20
21
  + 'A session that declared no scope is woken by mail addressed to it and by broadcasts only, and gets one notice event saying so on connect: '
21
22
  + 'declare the scope with orboto_session_start { scope: { role, projectKeys } } to be woken by the account\'s project mail again - '
22
- + 'the rest of the account\'s inbox stays readable with orboto_messages the whole time.';
23
+ + 'the rest of the account\'s inbox stays readable with orboto_messages the whole time. '
24
+ + 'Declare it once: the session keeps its id and scope across reconnects and restarts of this terminal.';
23
25
  const START_FLAG = '--dangerously-load-development-channels server:orboto';
24
26
  /** ORB-2148 - the commands an agent hands its operator verbatim; docs/claude-code.md carries the same lines. */
25
27
  export const CHANNEL_START_COMMANDS = {
@@ -119,6 +121,8 @@ export class InboxChannel {
119
121
  batch = [];
120
122
  /** Counters an operator can read from the log; tests read them directly. */
121
123
  stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0, notices: 0 };
124
+ /** ORB-2209 - what the server said on the last connect. */
125
+ session = null;
122
126
  constructor(opts) {
123
127
  this.opts = opts;
124
128
  this.log = opts.log ?? ((msg) => { try {
@@ -179,6 +183,12 @@ export class InboxChannel {
179
183
  }
180
184
  await this.emit({ content, meta: { kind: 'notice', notice } });
181
185
  }
186
+ /** ORB-2209 - the id and scope the server resumed; never a channel event. */
187
+ applySession(session) {
188
+ this.session = session;
189
+ confirmScope(this.opts.instanceToken, session.scope ?? null);
190
+ this.log(`inbox session ${session.id.slice(0, 8)}${session.resumed ? ` resumed (reconnect ${session.reconnects})` : ''}${session.scopeRestored ? ', scope re-declared' : ''}`);
191
+ }
182
192
  /** ORB-2181 - an admin restart notice becomes the supervisor's request file. */
183
193
  takeRestartRequest(reason) {
184
194
  try {
@@ -237,7 +247,9 @@ export class InboxChannel {
237
247
  const fetchFn = this.opts.fetchFn ?? fetch;
238
248
  this.abort = new AbortController();
239
249
  const since = this.lastId ?? this.since;
240
- const url = `${this.opts.baseUrl.replace(/\/$/, '')}/v1/agent/messages/stream?since=${encodeURIComponent(since)}`;
250
+ const resent = rememberedScope(this.opts.instanceToken);
251
+ const url = `${this.opts.baseUrl.replace(/\/$/, '')}/v1/agent/messages/stream?since=${encodeURIComponent(since)}&sessionFrame=1`
252
+ + (resent ? `&declaredScope=${encodeURIComponent(JSON.stringify(resent))}` : '');
241
253
  const bearer = this.opts.tokenProvider ? await this.opts.tokenProvider.getAccessToken() : (this.opts.apiKey ?? '');
242
254
  const res = await fetchFn(url, {
243
255
  method: 'GET',
@@ -280,6 +292,10 @@ export class InboxChannel {
280
292
  catch {
281
293
  continue;
282
294
  }
295
+ if (parsed.session) {
296
+ this.applySession(parsed.session);
297
+ continue;
298
+ }
283
299
  if (parsed.notice) {
284
300
  await this.deliverNotice(parsed.notice, parsed.content ?? '');
285
301
  continue;
@@ -7,6 +7,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
7
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
8
  import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
9
9
  import { buildOrbotoMcpServer } from './server.js';
10
+ import { _forgetDeclaredScopes, rememberDeclaredScope, rememberedScope } from './session-scope-memory.js';
10
11
  function mockMcp() {
11
12
  const notification = vi.fn().mockResolvedValue(undefined);
12
13
  return { mcp: { server: { notification } }, notification };
@@ -97,6 +98,52 @@ describe('InboxChannel', () => {
97
98
  expect(channel.stats.reconnects).toBeGreaterThanOrEqual(1);
98
99
  channel.close();
99
100
  });
101
+ it('ORB-2209 - every reconnect sends the same instance token, re-sends the declared scope and applies the session frame without an event', async () => {
102
+ _forgetDeclaredScopes();
103
+ rememberDeclaredScope('mcp-proc', { role: 'worker', projectKeys: ['ORB'] });
104
+ const { mcp, notification } = mockMcp();
105
+ const frame = (count) => ({ session: { id: 'sess-1111', scope: { role: 'worker', projectKeys: ['ORB'] }, resumed: count > 0, scopeRestored: false, reconnects: count, lastReconnectAt: count ? '2026-09-18T10:00:02.000Z' : null } });
106
+ const fetchFn = vi.fn()
107
+ .mockResolvedValueOnce(sse([frame(0)]))
108
+ .mockResolvedValueOnce(sse([frame(1)]))
109
+ .mockImplementation(() => new Promise(() => { }));
110
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
111
+ channel.start();
112
+ await vi.waitFor(() => expect(channel.session?.reconnects).toBe(0));
113
+ await vi.advanceTimersByTimeAsync(2_000);
114
+ await vi.waitFor(() => expect(channel.session?.reconnects).toBe(1));
115
+ expect(fetchFn.mock.calls.length).toBeGreaterThanOrEqual(2);
116
+ for (const [url, init] of fetchFn.mock.calls.slice(0, 2)) {
117
+ expect(init.headers['x-orboto-agent-session']).toBe('mcp-proc');
118
+ expect(String(url)).toContain('sessionFrame=1');
119
+ expect(decodeURIComponent(String(url))).toContain('declaredScope={"role":"worker","projectKeys":["ORB"]}');
120
+ }
121
+ expect(channel.session).toMatchObject({ id: 'sess-1111', resumed: true, scope: { projectKeys: ['ORB'] } });
122
+ expect(notification).not.toHaveBeenCalled();
123
+ channel.close();
124
+ });
125
+ it('ORB-2209 - a proxy that never declared a scope, or cleared it, re-sends nothing; a server-confirmed scope replaces the remembered one', async () => {
126
+ _forgetDeclaredScopes();
127
+ const { mcp } = mockMcp();
128
+ const fetchFn = vi.fn().mockImplementation(() => new Promise(() => { }));
129
+ const quiet = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-never', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
130
+ quiet.start();
131
+ await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
132
+ expect(String(fetchFn.mock.calls[0][0])).not.toContain('declaredScope');
133
+ quiet.close();
134
+ rememberDeclaredScope('mcp-cleared', null);
135
+ expect(rememberedScope('mcp-cleared')).toBeNull();
136
+ rememberDeclaredScope('mcp-changed', { projectKeys: ['OLD'] });
137
+ const changed = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-changed', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
138
+ changed.applySession({ id: 's', scope: { projectKeys: ['NEW'] }, resumed: true, scopeRestored: false, reconnects: 3, lastReconnectAt: null });
139
+ expect(rememberedScope('mcp-changed')).toEqual({ projectKeys: ['NEW'] });
140
+ changed.applySession({ id: 's', scope: null, resumed: true, scopeRestored: false, reconnects: 4, lastReconnectAt: null });
141
+ expect(rememberedScope('mcp-changed')).toEqual({ projectKeys: ['NEW'] });
142
+ rememberDeclaredScope('mcp-never-2', null);
143
+ const other = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-untouched', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
144
+ other.applySession({ id: 's', scope: { projectKeys: ['SRV'] }, resumed: false, scopeRestored: false, reconnects: 0, lastReconnectAt: null });
145
+ expect(rememberedScope('mcp-untouched')).toBeNull();
146
+ });
100
147
  it('ORB-2151 - a server notice becomes its own event and never becomes the replay anchor', async () => {
101
148
  const { mcp, notification } = mockMcp();
102
149
  const hint = { notice: 'declare_scope', sessionId: 'cccccccc-0000-4000-8000-000000000000', content: 'declare your scope: orboto_session_start { scope: { role, projectKeys } }' };
@@ -159,3 +159,33 @@ describe('ORB-1805 - minimal manifest fits a small context window', () => {
159
159
  expect(curated.instructionsChars).toBeGreaterThan(loaded.instructionsChars);
160
160
  });
161
161
  });
162
+ /**
163
+ * ORB-2210 - the `channel` tier registers no tool, so its whole per-turn
164
+ * cost is the instructions block; 400 tokens is the ceiling the ticket set.
165
+ */
166
+ const CHANNEL_MAX_TOKENS = 400;
167
+ async function channelInstructions(channel) {
168
+ const server = await buildOrbotoMcpServer({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'channel', channel });
169
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
170
+ const client = new Client({ name: 'manifest-measure', version: '0.0.0' });
171
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
172
+ try {
173
+ const tools = client.getServerCapabilities()?.tools ? (await client.listTools()).tools.length : 0;
174
+ return { tools, instructionsChars: (client.getInstructions() ?? '').length };
175
+ }
176
+ finally {
177
+ await client.close();
178
+ await server.close();
179
+ }
180
+ }
181
+ describe('ORB-2210 - the channel manifest costs only its instructions', () => {
182
+ it('channel: zero tools, instructions with the wake paragraph stay under 400 estimated tokens', async () => {
183
+ const withWake = await channelInstructions(true);
184
+ const bare = await channelInstructions(false);
185
+ process.stdout.write(`[manifest-size] channel: ${withWake.tools} tools, instructions ${withWake.instructionsChars} chars (~${estTokens(withWake.instructionsChars)} tokens)\n`);
186
+ expect(withWake.tools).toBe(0);
187
+ expect(bare.tools).toBe(0);
188
+ expect(estTokens(withWake.instructionsChars)).toBeLessThanOrEqual(CHANNEL_MAX_TOKENS);
189
+ expect(bare.instructionsChars).toBeLessThan(withWake.instructionsChars);
190
+ });
191
+ });
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import { OrbotoClient } from './orboto-client.js';
8
8
  import { registerOrbotoResources } from './resources.js';
9
9
  import { registerOrbotoPrompts } from './prompts.js';
10
10
  import { registerWithMetrics } from './with-metrics.js';
11
- import { resolveToolset, toolInToolset } from './toolset.js';
11
+ import { CHANNEL_TOOLSET_INSTRUCTIONS, CHANNEL_TOOLSET_WAKE_INSTRUCTIONS, resolveToolset, toolInToolset } from './toolset.js';
12
12
  import { createNudgeState } from './session-nudge.js';
13
13
  import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
14
14
  import { draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler } from './tools/customer-draft.js';
@@ -144,36 +144,40 @@ export async function buildOrbotoMcpServer(opts) {
144
144
  const client = new OrbotoClient(opts);
145
145
  const toolset = resolveToolset(opts.toolset, process.env.ORBOTO_MCP_TOOLSET);
146
146
  let workingRules = FALLBACK_WORKING_RULES;
147
- let requireSessionStart = true;
148
- try {
149
- const connectParams = new URLSearchParams();
150
- applyAgentProfile(connectParams);
151
- const connectQs = connectParams.toString();
152
- const res = await loadRequiredRules(client, `/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
153
- if (res?.instructions?.trim())
154
- workingRules = res.instructions.trim();
155
- requireSessionStart = res.requireSessionStart ?? true;
156
- }
157
- catch {
158
- 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.';
147
+ let requireSessionStart = toolset !== 'channel';
148
+ if (toolset !== 'channel') {
149
+ try {
150
+ const connectParams = new URLSearchParams();
151
+ applyAgentProfile(connectParams);
152
+ const connectQs = connectParams.toString();
153
+ const res = await loadRequiredRules(client, `/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
154
+ if (res?.instructions?.trim())
155
+ workingRules = res.instructions.trim();
156
+ requireSessionStart = res.requireSessionStart ?? true;
157
+ }
158
+ catch {
159
+ 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.';
160
+ }
159
161
  }
160
162
  const channelHint = opts.channel ? [CHANNEL_INSTRUCTIONS] : [];
161
163
  const server = new McpServer({ name: 'orboto', version: VERSION }, {
162
164
  capabilities: {
163
- resources: { subscribe: true, listChanged: true },
165
+ ...(toolset === 'channel' ? {} : { resources: { subscribe: true, listChanged: true } }),
164
166
  ...(opts.channel ? { experimental: { [CHANNEL_CAPABILITY]: {} } } : {}),
165
167
  },
166
- instructions: toolset === 'minimal'
167
- ? [
168
- staticMcpHints(toolset),
169
- '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.',
170
- ...channelHint,
171
- ].join('\n\n')
172
- : assembleInstructions([
173
- staticMcpHints(toolset),
174
- '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.',
175
- ...channelHint,
176
- ].join('\n\n'), workingRules),
168
+ instructions: toolset === 'channel'
169
+ ? [CHANNEL_TOOLSET_INSTRUCTIONS, ...(opts.channel ? [CHANNEL_TOOLSET_WAKE_INSTRUCTIONS] : [])].join('\n\n')
170
+ : toolset === 'minimal'
171
+ ? [
172
+ staticMcpHints(toolset),
173
+ '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.',
174
+ ...channelHint,
175
+ ].join('\n\n')
176
+ : assembleInstructions([
177
+ staticMcpHints(toolset),
178
+ '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.',
179
+ ...channelHint,
180
+ ].join('\n\n'), workingRules),
177
181
  });
178
182
  const nudgeState = createNudgeState(requireSessionStart);
179
183
  const regAll = registerWithMetrics(server, client, opts.userAgentSuffix, nudgeState);
@@ -369,6 +373,8 @@ export async function buildOrbotoMcpServer(opts) {
369
373
  reg('orboto_admin_translation_revert', revertTranslationToolConfig, makeAdminRevertTranslationHandler(client));
370
374
  reg('orboto_admin_agent_drift_list', listAgentDriftToolConfig, makeListAgentDriftHandler(client));
371
375
  reg('orboto_admin_agent_drift_resolve', resolveAgentDriftToolConfig, makeResolveAgentDriftHandler(client));
376
+ if (toolset === 'channel')
377
+ return server;
372
378
  registerOrbotoResources(server, client);
373
379
  registerOrbotoPrompts(server);
374
380
  if (opts.subscriptions) {
@@ -0,0 +1,18 @@
1
+ const declared = new Map();
2
+ /** A declaration made through this process: a scope, or null for a deliberate clear. */
3
+ export function rememberDeclaredScope(instanceToken, scope) {
4
+ declared.set(instanceToken, scope && Object.keys(scope).length > 0 ? scope : null);
5
+ }
6
+ /** The scope the server confirmed on connect replaces the remembered one, once this process declared any. */
7
+ export function confirmScope(instanceToken, scope) {
8
+ if (declared.has(instanceToken) && scope)
9
+ declared.set(instanceToken, scope);
10
+ }
11
+ /** What a reconnect re-sends: the last declared scope, or null. */
12
+ export function rememberedScope(instanceToken) {
13
+ return declared.get(instanceToken) ?? null;
14
+ }
15
+ /** Test seam. */
16
+ export function _forgetDeclaredScopes() {
17
+ declared.clear();
18
+ }
@@ -42,7 +42,7 @@ export function makeAgentHeartbeatHandler(client) {
42
42
  }
43
43
  export const agentPresenceToolConfig = {
44
44
  title: 'Workspace agent presence',
45
- description: 'Returns all active orboto agent instances, including lane workers/reviewers, inbox and MCP connections and registered runners. Session freshness is 90 seconds; lanes retain their 300-second heartbeat grace. Unexpired work leases keep their owning instance visible. Ticket metadata and claims retain ordinary ticket visibility. Rows include `kind` (agent or human, classified by the credential: agent API key, OAuth grant, lane, external token or bot account; `isBot` is its alias), `actsAs` (the human account an AI client acts under), `projects` (memberships plus lane projects), `connections` (type + label per credential or attachment, `live_events` for the MCP event bridge), owner, lane and stable sessionId; separate instances of one account remain separate. Callers with admin:system:read see every agent; other users see only their own sessions (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t step on a parallel run. Filters `projectKey` (rows whose `projects` contain the key) and `kind` (agent | human) narrow the list client-side, the same way `orboto agents --project KEY --bots|--humans` does.',
45
+ description: 'Returns all active orboto agent instances, including lane workers/reviewers, inbox and MCP connections and registered runners. Session freshness is 90 seconds; lanes retain their 300-second heartbeat grace. Unexpired work leases keep their owning instance visible. Ticket metadata and claims retain ordinary ticket visibility. Rows include `kind` (agent or human, classified by the credential: agent API key, OAuth grant, lane, external token or bot account; `isBot` is its alias), `actsAs` (the human account an AI client acts under), `projects` (memberships plus lane projects), `connections` (type + label per credential or attachment, `live_events` for the MCP event bridge), owner, lane and stable sessionId; separate instances of one account remain separate. Callers with admin:system:read see every agent; other users see only their own sessions. Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, the ticket it works on, and `reconnects` survived under the same id and scope. Check it before dispatching a sub-task so you do not step on a parallel run. Filters `projectKey` (rows whose `projects` contain the key) and `kind` (agent | human) narrow the list client-side, the same way `orboto agents --project KEY --bots|--humans` does.',
46
46
  inputSchema: z.object({
47
47
  projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
48
48
  kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
@@ -4,6 +4,8 @@ const WorkSessionResourceClaimSchema = z.object({
4
4
  kind: z.enum(['path', 'named']), value: z.string().min(1).max(500), mode: z.enum(['read', 'write']),
5
5
  state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
6
6
  });
7
+ /** ORB-2209 - one account reference, shared by `actsAs` and `owner`. */
8
+ const AgentAccountRefSchema = z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() });
7
9
  export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
8
10
  export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web', 'channel']);
9
11
  export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
@@ -15,8 +17,8 @@ export const AgentInventoryEntrySchema = z.object({
15
17
  kind: AgentInventoryKindSchema,
16
18
  /** Alias of `kind === 'agent'`. */
17
19
  isBot: z.boolean(),
18
- actsAs: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
19
- owner: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
20
+ actsAs: AgentAccountRefSchema.nullable(),
21
+ owner: AgentAccountRefSchema.nullable(),
20
22
  autonomyPaused: z.boolean(),
21
23
  sessionId: z.string().uuid(),
22
24
  status: z.string(),
@@ -51,6 +53,9 @@ export const AgentInventoryEntrySchema = z.object({
51
53
  restartReason: z.string().nullable(),
52
54
  /** ORB-2189 - the answering instance holds this session's live wake channel, so a restart request can reach it. */
53
55
  restartable: z.boolean(),
56
+ /** ORB-2209 - stream reconnects this row survived; the id and the scope stayed the same. */
57
+ reconnects: z.number().int(),
58
+ lastReconnectAt: z.string().nullable(),
54
59
  lastSeenAt: z.string(),
55
60
  createdAt: z.string(),
56
61
  });
@@ -15,6 +15,7 @@ import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
15
15
  import { loadRequiredRules } from '../required-rules.js';
16
16
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
17
17
  import { channelStartLines } from '../inbox-channel.js';
18
+ import { rememberDeclaredScope } from '../session-scope-memory.js';
18
19
  import { agentHeadText } from '../agent-head.js';
19
20
  export const sessionStartToolConfig = {
20
21
  title: 'Load the rules you must follow + re-orient',
@@ -130,6 +131,12 @@ async function buildTicketBundle(client, ticketKey) {
130
131
  },
131
132
  };
132
133
  }
134
+ /** ORB-2209 - a declared scope stays with the instance token; the agent declares it once per terminal. */
135
+ export function identityLine(registration) {
136
+ const count = registration.reconnects ?? 0;
137
+ const kept = count > 0 ? ` It survived ${count} reconnect(s), the last at ${registration.lastReconnectAt ?? 'an unknown time'}.` : '';
138
+ return `This scope stays with this session across reconnects and restarts; declare it again only to change it.${kept}`;
139
+ }
133
140
  export function makeSessionStartHandler(client, opts = {}) {
134
141
  let lastKnownRulesHash;
135
142
  return async (input = {}, extra) => {
@@ -169,8 +176,10 @@ export function makeSessionStartHandler(client, opts = {}) {
169
176
  };
170
177
  }
171
178
  const registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
172
- .then((r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null } : null))
179
+ .then((r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null))
173
180
  .catch(() => null);
181
+ if (input.scope !== undefined && registration)
182
+ rememberDeclaredScope(instanceToken, registration.scope);
174
183
  const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
175
184
  client.get('/users/me').catch(() => null),
176
185
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
@@ -258,6 +267,8 @@ export function makeSessionStartHandler(client, opts = {}) {
258
267
  ? [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('; ')
259
268
  : 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it';
260
269
  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}" }.`);
270
+ if (registration?.scope)
271
+ lines.push(identityLine(registration));
261
272
  if (opts.channel)
262
273
  lines.push('', ...channelStartLines());
263
274
  if (pendingMessages.length > 0) {
@@ -289,7 +300,10 @@ export function makeSessionStartHandler(client, opts = {}) {
289
300
  })),
290
301
  ...(elsewhereCount > 0 ? { inProgressElsewhereCount: elsewhereCount } : {}),
291
302
  timer: timer?.ticketId ? { ticketKey: timer.ticketKey ?? null, startedAt: timer.startedAt ?? null } : null,
292
- session: { ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null },
303
+ session: {
304
+ ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null,
305
+ reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
306
+ },
293
307
  gitHealth: {
294
308
  unhealthy: gitHealthWithConnections
295
309
  .map((p) => ({ projectId: p.projectId, connections: p.connections.filter((c) => !c.healthy) }))
@@ -9,6 +9,7 @@
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { OrbotoClient } from '../orboto-client.js';
11
11
  import { makeSessionStartHandler } from './session-start.js';
12
+ import { _forgetDeclaredScopes, rememberedScope } from '../session-scope-memory.js';
12
13
  import { CHANNEL_START_COMMANDS, CHANNEL_START_COMMANDS_WITHOUT_CLI } from '../inbox-channel.js';
13
14
  import { readFileSync } from 'node:fs';
14
15
  import { makeResponseExpandHandler } from './response-expand.js';
@@ -554,6 +555,22 @@ describe('ORB-2136 - session scope and ref', () => {
554
555
  expect(text).toContain('from spec@orboto.io (spec, s1)');
555
556
  expect(res.structuredContent).toMatchObject({ session: { ref: 'mcp-abc123', id: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } } });
556
557
  });
558
+ it('ORB-2209 - reports the kept scope with its reconnects, and remembers the declaration for the channel', async () => {
559
+ _forgetDeclaredScopes();
560
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
561
+ const path = new URL(url.toString()).pathname;
562
+ const body = path === '/v1/agent/heartbeat'
563
+ ? { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'worker', projectKeys: ['ORB'] }, reconnects: 6, lastReconnectAt: '2026-09-21T23:01:22.000Z' }
564
+ : path === '/agent-instructions' ? { instructions: 'rules here', rulesHash: 'fixture' } : {};
565
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
566
+ });
567
+ const reread = await makeSessionStartHandler(client)({}, { sessionId: 'keep1' });
568
+ expect(reread.content[0].text).toContain('stays with this session across reconnects and restarts; declare it again only to change it. It survived 6 reconnect(s), the last at 2026-09-21T23:01:22.000Z.');
569
+ expect(reread.structuredContent).toMatchObject({ session: { reconnects: 6, reconnectedAt: '2026-09-21T23:01:22.000Z' } });
570
+ expect(rememberedScope('mcp-keep1')).toBeNull();
571
+ await makeSessionStartHandler(client)({ scope: { role: 'worker', projectKeys: ['orb'] } }, { sessionId: 'keep1' });
572
+ expect(rememberedScope('mcp-keep1')).toEqual({ role: 'worker', projectKeys: ['ORB'] });
573
+ });
557
574
  it('without a scope the heartbeat body stays empty and the digest says no scope is declared', async () => {
558
575
  const seen = [];
559
576
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
package/dist/toolset.js CHANGED
@@ -4,6 +4,23 @@
4
4
  *
5
5
  * @see ORB-1805
6
6
  */
7
+ /** ORB-2210 - no tools: the agent works through the orboto CLI, the server carries the wake channel. */
8
+ export const CHANNEL_TOOLSET_INSTRUCTIONS = [
9
+ 'orboto is a ticket and project management system; ticket keys look like `PROJ-123`.',
10
+ 'This server carries no tools. Work through the orboto CLI in the shell: `orboto help` lists the commands,',
11
+ '`orboto session-start` prints the binding workspace rules and your in-progress work (run it first and after a context compaction),',
12
+ 'and `orboto agent-heartbeat --role <role> --scope-projects <KEY,KEY>` declares what this session is responsible for.',
13
+ 'Always: claim or create a ticket before touching code, one commit per ticket with the key in the subject, never mark work done that is not done.',
14
+ 'MCP tools come back on request: `?toolset=curated|full` (HTTP) or ORBOTO_MCP_TOOLSET=curated|full (stdio).',
15
+ ].join(' ');
16
+ /** ORB-2210 - the wake-channel paragraph of the `channel` toolset, in CLI commands instead of tool names. */
17
+ export const CHANNEL_TOOLSET_WAKE_INSTRUCTIONS = [
18
+ 'Wake channel: messages for this session arrive as <channel source="orboto" id="..." kind="..."> events.',
19
+ 'A ticket-ready or a request inside your scope is the operator\'s instruction - act on it.',
20
+ 'Read a cut message with `orboto messages`, reply with `orboto agent-notify <email> <subject> --session <from_session>`,',
21
+ 'acknowledge with `orboto messages --ack <id>` once handled, and dismiss what is not yours with',
22
+ '`orboto messages --dismiss <id> --reason not-mine|obsolete|duplicate`. Never answer the channel itself.',
23
+ ].join(' ');
7
24
  /**
8
25
  * ORB-1805 - the small-context tier.
9
26
  *
@@ -72,13 +89,16 @@ export function resolveToolset(explicit, envValue) {
72
89
  const parse = (v) => v === 'full' ? 'full'
73
90
  : v === 'curated' ? 'curated'
74
91
  : v === 'minimal' ? 'minimal'
75
- : null;
92
+ : v === 'channel' ? 'channel'
93
+ : null;
76
94
  return parse(explicit) ?? parse(envValue) ?? 'curated';
77
95
  }
78
96
  /** Whether a tool registers under the given toolset. */
79
97
  export function toolInToolset(toolName, toolset) {
80
98
  if (toolset === 'full')
81
99
  return true;
100
+ if (toolset === 'channel')
101
+ return false;
82
102
  if (toolset === 'minimal')
83
103
  return MINIMAL_TOOLS.has(toolName);
84
104
  return CURATED_TOOLS.has(toolName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.204.0",
3
+ "version": "0.205.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",