@orboto/mcp 0.203.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 +1 -1
- package/dist/channel-toolset.test.js +90 -0
- package/dist/connect-script.js +1 -1
- package/dist/connect-script.test.js +1 -1
- package/dist/inbox-channel.js +18 -2
- package/dist/inbox-channel.test.js +47 -0
- package/dist/manifest-size.test.js +30 -0
- package/dist/server.js +31 -25
- package/dist/session-scope-memory.js +18 -0
- package/dist/tools/agent-coordination.js +1 -1
- package/dist/tools/agent-presence-schema.js +7 -2
- package/dist/tools/list-projects.js +19 -13
- package/dist/tools/list-projects.test.js +21 -0
- package/dist/tools/phase-b-tools.test.js +21 -0
- package/dist/tools/search.js +9 -6
- package/dist/tools/session-start.js +16 -2
- package/dist/tools/session-start.test.js +17 -0
- package/dist/tools/ticket-writes.js +4 -3
- package/dist/tools/ticket-writes.test.js +19 -0
- package/dist/toolset.js +21 -1
- package/package.json +1 -1
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
|
+
});
|
package/dist/connect-script.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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();
|
package/dist/inbox-channel.js
CHANGED
|
@@ -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
|
|
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 =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 === '
|
|
167
|
-
? [
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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:
|
|
19
|
-
owner:
|
|
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
|
});
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export const listProjectsToolConfig = {
|
|
6
6
|
title: 'List projects',
|
|
7
|
-
description: '
|
|
8
|
-
+ '
|
|
9
|
-
+ '
|
|
10
|
-
+ 'When the result says it is partial, refine with `query` rather than re-calling.',
|
|
7
|
+
description: 'Projects the user can see. To find one, pass `search` (key, name, description, customer) '
|
|
8
|
+
+ 'instead of pulling the whole list; a project key usually works directly with other tools. '
|
|
9
|
+
+ 'When the result is partial, refine rather than re-call.',
|
|
11
10
|
inputSchema: z.object({
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
search: z.string().optional().describe('Key, name, description, customer.'),
|
|
12
|
+
query: z.string().optional().describe('Key/name substring.'),
|
|
13
|
+
limit: z.number().int().min(1).max(200).optional().describe('Default 50.'),
|
|
14
14
|
}).shape,
|
|
15
15
|
outputSchema: z.object({
|
|
16
16
|
projects: z.array(z.object({
|
|
@@ -27,22 +27,28 @@ export const listProjectsToolConfig = {
|
|
|
27
27
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
28
28
|
};
|
|
29
29
|
export function makeListProjectsHandler(client) {
|
|
30
|
-
return async ({ query, limit } = {}) => {
|
|
31
|
-
const
|
|
30
|
+
return async ({ search, query, limit } = {}) => {
|
|
31
|
+
const s = (search ?? '').trim();
|
|
32
|
+
const [projects, found] = await Promise.all([
|
|
33
|
+
client.get('/projects'),
|
|
34
|
+
s ? client.get(`/projects?search=${encodeURIComponent(s)}`) : Promise.resolve(null),
|
|
35
|
+
]);
|
|
32
36
|
const q = (query ?? '').trim().toLowerCase();
|
|
37
|
+
const base = found ?? projects;
|
|
33
38
|
const matched = q
|
|
34
|
-
?
|
|
35
|
-
:
|
|
39
|
+
? base.filter((p) => `${p.key} ${p.name}`.toLowerCase().includes(q))
|
|
40
|
+
: base;
|
|
41
|
+
const term = [s, q].filter(Boolean).map((v) => `"${v}"`).join(' and ');
|
|
36
42
|
const cap = Math.min(limit ?? 50, 200);
|
|
37
43
|
const shown = matched.slice(0, cap);
|
|
38
44
|
const rows = shown.map((p) => ({ id: p.id, key: p.key, name: p.name, status: p.status, description: p.description }));
|
|
39
45
|
const lines = rows.map((r) => `- ${r.key} - ${r.name} (${r.status})`);
|
|
40
46
|
const partial = shown.length < matched.length;
|
|
41
47
|
const footer = matched.length === 0
|
|
42
|
-
? (
|
|
48
|
+
? (term ? `No projects match ${term}.` : 'No projects visible to this user.')
|
|
43
49
|
: partial
|
|
44
|
-
? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower
|
|
45
|
-
: `\n(${matched.length} project(s)${
|
|
50
|
+
? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower search to filter.`
|
|
51
|
+
: `\n(${matched.length} project(s)${term ? ` matching ${term}` : ''}, complete.)`;
|
|
46
52
|
const text = matched.length === 0 ? footer : lines.join('\n') + footer;
|
|
47
53
|
return {
|
|
48
54
|
content: [{ type: 'text', text }],
|
|
@@ -57,6 +57,27 @@ describe('tools/list-projects', () => {
|
|
|
57
57
|
expect(text).toContain('Showing first 2 of 5 match(es)');
|
|
58
58
|
expect(result.structuredContent).toMatchObject({ total: 5, totalProjects: 5 });
|
|
59
59
|
});
|
|
60
|
+
it('ORB-2203: search asks the API with ?search= and still counts every visible project', async () => {
|
|
61
|
+
const all = [
|
|
62
|
+
{ id: 'p1', key: 'ACME', name: 'Acme', description: 'Customer portal', status: 'active' },
|
|
63
|
+
{ id: 'p2', key: 'TOOL', name: 'Internal Tools', description: null, status: 'draft' },
|
|
64
|
+
];
|
|
65
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
66
|
+
const url = String(input);
|
|
67
|
+
const body = url.includes('search=') ? [all[0]] : all;
|
|
68
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body };
|
|
69
|
+
});
|
|
70
|
+
const result = await makeListProjectsHandler(client)({ search: ' portal ' });
|
|
71
|
+
const urls = fetchSpy.mock.calls.map(([input]) => String(input));
|
|
72
|
+
expect(urls.some((u) => u.endsWith('/projects?search=portal'))).toBe(true);
|
|
73
|
+
expect(result.content[0]).toEqual({ type: 'text', text: '- ACME - Acme (active)\n(1 project(s) matching "portal", complete.)' });
|
|
74
|
+
expect(result.structuredContent).toMatchObject({ total: 1, totalProjects: 2, query: null });
|
|
75
|
+
});
|
|
76
|
+
it('ORB-2203: without search only the plain list is fetched', async () => {
|
|
77
|
+
const fetchSpy = mockFetch([]);
|
|
78
|
+
await makeListProjectsHandler(client)({ query: 'x' });
|
|
79
|
+
expect(fetchSpy.mock.calls.map(([input]) => String(input)).every((u) => !u.includes('search='))).toBe(true);
|
|
80
|
+
});
|
|
60
81
|
it('renders an empty-state text block when the API returns no rows', async () => {
|
|
61
82
|
mockFetch([]);
|
|
62
83
|
const result = await makeListProjectsHandler(client)();
|
|
@@ -490,6 +490,27 @@ describe('orboto_search', () => {
|
|
|
490
490
|
expect(sc.total).toBe(42);
|
|
491
491
|
expect(sc.hasMore).toBe(true);
|
|
492
492
|
});
|
|
493
|
+
it('ORB-2201: passes the jump types and renders project and milestone hits by key', async () => {
|
|
494
|
+
const calls = stub([
|
|
495
|
+
{
|
|
496
|
+
json: {
|
|
497
|
+
items: [
|
|
498
|
+
{ type: 'project', id: 'p1', key: 'ACME', projectKey: 'ACME', title: 'Acme', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme', rank: 2000 },
|
|
499
|
+
{ type: 'milestone', id: 'm1', key: 'ACME-M1', projectKey: 'ACME', title: 'Launch', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme?milestoneIds=m1', rank: 200 },
|
|
500
|
+
],
|
|
501
|
+
nextCursor: null,
|
|
502
|
+
total: 2,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
const res = await makeSearchHandler(client)({ query: 'acme', types: ['project', 'milestone'] });
|
|
507
|
+
expect(calls[0]).toContain('types=project%2Cmilestone');
|
|
508
|
+
const text = res.content[0].text;
|
|
509
|
+
expect(text).toContain('- [PROJECT ACME] Acme');
|
|
510
|
+
expect(text).toContain('- [MILESTONE ACME-M1 · Acme] Launch');
|
|
511
|
+
const sc = res.structuredContent;
|
|
512
|
+
expect(sc.hits.map((h) => [h.key, h.projectKey])).toEqual([['ACME', 'ACME'], ['ACME-M1', 'ACME']]);
|
|
513
|
+
});
|
|
493
514
|
});
|
|
494
515
|
describe('doc tools', () => {
|
|
495
516
|
it('list: uses DocSpace.type to label scope, not a joined projectName', async () => {
|
package/dist/tools/search.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { resolveProjectByKey } from './shared.js';
|
|
8
|
+
const SEARCH_TYPES = ['ticket', 'comment', 'doc', 'project', 'milestone', 'space', 'customer'];
|
|
8
9
|
export const searchToolConfig = {
|
|
9
10
|
title: 'Search across orboto',
|
|
10
|
-
description: '
|
|
11
|
+
description: 'Search tickets, comments and docs; `types` adds projects, milestones, spaces, customers. Content matches full text, jump types match key and name; `types` defaults to ticket, comment, doc. Honours the caller\'s visibility - private tickets and internal comments never appear unless the caller can already see them. Recall (ORB-1695): when the strict all-terms pass finds nothing, the server automatically retries with an OR-relaxed pass plus semantic (embedding) recall - the response\'s `pass` field says which pass produced the hits, and relaxed-pass hits deserve a skeptical read (they matched SOME terms or only the meaning, not all terms). Still prefer a single distinctive STABLE token (file/component/error-string fragment like "AdminCodesPage") and search the SYMPTOM, not your intended fix.',
|
|
11
12
|
inputSchema: z.object({
|
|
12
13
|
query: z.string().min(1).describe('Search terms, e.g. "queue worker retry".'),
|
|
13
|
-
types: z.array(z.enum(
|
|
14
|
-
.describe('Restrict to entity types. Omit for all.'),
|
|
14
|
+
types: z.array(z.enum(SEARCH_TYPES)).optional(),
|
|
15
15
|
projectKey: z.string().optional().describe('Restrict to one project.'),
|
|
16
16
|
limit: z.number().int().min(1).max(50).default(15),
|
|
17
17
|
}).shape,
|
|
@@ -39,9 +39,10 @@ export function makeSearchHandler(client) {
|
|
|
39
39
|
? `No hits for "${input.query}".`
|
|
40
40
|
: `Hits${headerHint}${relaxedNote}:\n\n` + res.items.map((h) => {
|
|
41
41
|
const tag = h.type.toUpperCase();
|
|
42
|
-
const ident = h.ticketKey ?? h.id;
|
|
43
|
-
const project = h.projectName ? ` · ${h.projectName}` : '';
|
|
44
|
-
|
|
42
|
+
const ident = h.ticketKey ?? h.key ?? h.id;
|
|
43
|
+
const project = h.projectName && h.type !== 'project' ? ` · ${h.projectName}` : '';
|
|
44
|
+
const excerpt = h.excerpt ? `\n ${h.excerpt}` : '';
|
|
45
|
+
return `- [${tag} ${ident}${project}] ${h.title}${excerpt}`;
|
|
45
46
|
}).join('\n\n');
|
|
46
47
|
return {
|
|
47
48
|
content: [{ type: 'text', text }],
|
|
@@ -56,6 +57,8 @@ export function makeSearchHandler(client) {
|
|
|
56
57
|
title: h.title,
|
|
57
58
|
excerpt: h.excerpt,
|
|
58
59
|
ticketKey: h.ticketKey ?? null,
|
|
60
|
+
key: h.key ?? h.ticketKey ?? null,
|
|
61
|
+
projectKey: h.projectKey ?? null,
|
|
59
62
|
projectName: h.projectName,
|
|
60
63
|
spaceName: h.spaceName,
|
|
61
64
|
url: h.url,
|
|
@@ -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: {
|
|
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) => {
|
|
@@ -186,7 +186,7 @@ function preflightResult(preflight, projectKey) {
|
|
|
186
186
|
preflight.ok
|
|
187
187
|
? `✓ Pre-flight clean for ${projectKey} - re-send the same arguments without dryRun to create the ticket.`
|
|
188
188
|
: `⛔ Pre-flight BLOCKED for ${projectKey} (${preflight.blockedBy.join(', ')}) - nothing was created. Fix the payload and run the pre-flight again.`,
|
|
189
|
-
` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}`,
|
|
189
|
+
` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}${preflight.verdicts.language.hint ? ` Keep it only on purpose: ${preflight.verdicts.language.hint.query}.` : ''}`,
|
|
190
190
|
` ${icon[preflight.verdicts.type.severity]} type: ${preflight.verdicts.type.message}`,
|
|
191
191
|
` ${icon[preflight.verdicts.labels.severity]} labels: ${preflight.verdicts.labels.message}`,
|
|
192
192
|
` ${icon[preflight.verdicts.duplicates.severity]} duplicates: ${preflight.verdicts.duplicates.message}`,
|
|
@@ -257,12 +257,13 @@ function languageBlockResult(err, verb) {
|
|
|
257
257
|
if (!parsed.languageWarning)
|
|
258
258
|
return null;
|
|
259
259
|
const lw = parsed.languageWarning;
|
|
260
|
+
const retry = `retry the same call with ${parsed.hint?.query ?? 'allowLanguageMismatch=true'}`;
|
|
260
261
|
const text = `⛔ ${verb} blocked - strict ticket-language enforcement is on.\n` +
|
|
261
262
|
`This content reads as "${lw.detected}" but the workspace language is "${lw.expected}".\n` +
|
|
262
|
-
`Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - retry
|
|
263
|
+
`Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - ${retry}.`;
|
|
263
264
|
return {
|
|
264
265
|
content: [{ type: 'text', text }],
|
|
265
|
-
structuredContent: { blocked: true, languageWarning: lw },
|
|
266
|
+
structuredContent: { blocked: true, languageWarning: lw, ...(parsed.hint ? { hint: parsed.hint } : {}) },
|
|
266
267
|
isError: true,
|
|
267
268
|
};
|
|
268
269
|
}
|
|
@@ -228,6 +228,25 @@ describe('orboto_create_ticket', () => {
|
|
|
228
228
|
expect(sc.blocked).toBe(true);
|
|
229
229
|
expect(sc.languageWarning.severity).toBe('block');
|
|
230
230
|
});
|
|
231
|
+
it('reads the way out from the hint, never from the sentence written for a person (ORB-2200)', async () => {
|
|
232
|
+
const blockBody = JSON.stringify({
|
|
233
|
+
error: 'This ticket looks like it is written in a different language (de) than this project expects (en). If the language is intentional, you can keep the ticket as it is.',
|
|
234
|
+
errorKey: 'errors.tickets.language_mismatch',
|
|
235
|
+
languageWarning: { code: 'language_mismatch', severity: 'block', detected: 'de', expected: 'en' },
|
|
236
|
+
hint: { query: 'allowLanguageMismatch=true' },
|
|
237
|
+
});
|
|
238
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
239
|
+
if ((init?.method ?? 'GET') === 'GET') {
|
|
240
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
241
|
+
}
|
|
242
|
+
return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
|
|
243
|
+
});
|
|
244
|
+
const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern' });
|
|
245
|
+
const text = res.content[0].text;
|
|
246
|
+
expect(text).toContain('retry the same call with allowLanguageMismatch=true');
|
|
247
|
+
expect(text).not.toContain('keep the ticket as it is');
|
|
248
|
+
expect(res.structuredContent.hint).toEqual({ query: 'allowLanguageMismatch=true' });
|
|
249
|
+
});
|
|
231
250
|
it('passes allowLanguageMismatch as a query param when the override is set (ORB-990)', async () => {
|
|
232
251
|
let createUrl = '';
|
|
233
252
|
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
|
-
:
|
|
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.
|
|
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",
|