@orboto/mcp 0.210.0 → 0.211.1
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/connect-script.js +2 -2
- package/dist/inbox-channel.js +25 -3
- package/dist/inbox-channel.test.js +49 -1
- package/dist/index.js +4 -1
- package/dist/probe-session.js +20 -0
- package/dist/probe-session.test.js +36 -0
- package/dist/tools/agent-coordination.js +11 -5
- package/dist/tools/agent-coordination.test.js +23 -1
- package/dist/tools/knowledge.js +2 -1
- package/dist/tools/knowledge.test.js +13 -0
- package/dist/tools/secret-scan.js +5 -2
- package/dist/tools/secret-scan.test.js +30 -0
- package/dist/tools/wiki.js +12 -4
- package/dist/tools/wiki.test.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Omit `ORBOTO_API_KEY` to use OAuth login instead. `codex mcp add` writes the sam
|
|
|
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. |
|
|
126
|
-
| `ORBOTO_MCP_CHANNEL_DIGEST_MINUTES` | no | Digest window for `info` / `complete` inbox mail on the channel. Default `15`; `0` delivers every message at once. Requests, errors, questions and mail older than five minutes always go out immediately. |
|
|
126
|
+
| `ORBOTO_MCP_CHANNEL_DIGEST_MINUTES` | no | Digest window for account-addressed or broadcast `info` / `complete` inbox mail on the channel. Default `15`; `0` delivers every message at once. Requests, errors, questions, mail addressed to this session, replies in a thread this session sent into within 24 hours and mail older than five minutes always go out immediately. |
|
|
127
127
|
|
|
128
128
|
## Version compatibility
|
|
129
129
|
|
package/dist/connect-script.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
export const CONNECT_SCRIPT_HEADING = 'If you are an AI agent helping a person set this up';
|
|
8
8
|
export const CONNECT_SCRIPT_STEPS = [
|
|
9
9
|
'Work one step per message: say in one sentence what the step does and why, run it, show the result, and only then move on.',
|
|
10
|
-
'Run every step you can run yourself - `orboto connect --yes
|
|
10
|
+
'Run every step you can run yourself - `orboto connect --yes --project <KEY>` wires this directory (profile pointer, project, the `orboto` server entry, one identity, the completion hook), probes the wake channel and ends with the checklist; exit code 0 means every line passed, and a failing line names its fix.',
|
|
11
11
|
'Hand the person only the steps that need a human: the browser login, the two `/plugin` commands that install the orboto plugin (a CLI cannot type into a running session), quitting and restarting the session with that command, confirming the servers from `.mcp.json` and, in the development form, the local-development prompt, and, on a Team or Enterprise plan, the one Owner switch that allows channels and lists the plugin.',
|
|
12
12
|
'Give every command the person runs verbatim in its own code block, never paraphrased and never merged into prose.',
|
|
13
|
-
'Verify before you continue: `orboto
|
|
13
|
+
'Verify before you continue: every line of the `orboto connect` checklist passes, `orboto doctor` prints the same checklist later, `orboto connect --verify` probes the running session; declare or change the scope with `orboto session-start` in the shell, never through the claude.ai connector.',
|
|
14
14
|
'Never paste a reference page into the chat - link it and keep the message to the step you are on.',
|
|
15
15
|
'Never ask for an API key, a password or a token in the chat; `orboto login` writes the credential itself.',
|
|
16
16
|
'When a check fails, name the cause from the troubleshooting table, fix it, and repeat the check before the next step.',
|
package/dist/inbox-channel.js
CHANGED
|
@@ -2,6 +2,8 @@ import { RESTART_REQUESTED_NOTICE, writeRestartRequest } from './session-restart
|
|
|
2
2
|
import { confirmScope, rememberedScope } from './session-scope-memory.js';
|
|
3
3
|
export { RESTART_REQUESTED_NOTICE };
|
|
4
4
|
export const CHANNEL_METHOD = 'notifications/claude/channel';
|
|
5
|
+
/** ORB-2263 - the server notice an unscoped channel session gets; the session sees it once, with the exact CLI call. */
|
|
6
|
+
export const DECLARE_SCOPE_NOTICE = 'declare_scope';
|
|
5
7
|
export const CHANNEL_CAPABILITY = 'claude/channel';
|
|
6
8
|
export const DEFAULT_DIGEST_MINUTES = 15;
|
|
7
9
|
const IMMEDIATE_AFTER_MS = 5 * 60_000;
|
|
@@ -19,7 +21,7 @@ export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session ar
|
|
|
19
21
|
+ 'Never answer the channel itself and never ack what you did not handle - dismiss it instead with orboto_messages { dismiss: { ids, reason } }: '
|
|
20
22
|
+ '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. '
|
|
21
23
|
+ '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: '
|
|
22
|
-
+ 'declare the scope
|
|
24
|
+
+ 'declare the scope through the CLI in the shell, `orboto session-start --role worker --scope-projects <KEY>`, to be woken by the account\'s project mail again - never through the claude.ai connector, which is another session - '
|
|
23
25
|
+ 'the rest of the account\'s inbox stays readable with orboto_messages the whole time. '
|
|
24
26
|
+ 'Declare it once: the session keeps its id and scope across reconnects and restarts of this terminal.';
|
|
25
27
|
const START_FLAG = '--dangerously-load-development-channels server:orboto';
|
|
@@ -65,12 +67,18 @@ function waitedMinutes(createdAt, now) {
|
|
|
65
67
|
return 0;
|
|
66
68
|
return Math.max(0, Math.floor((now - t) / 60_000));
|
|
67
69
|
}
|
|
68
|
-
/**
|
|
70
|
+
/** ORB-2265 - a message the stream flags as addressed to this session or answering a thread it sent into is never digest material. */
|
|
71
|
+
export function isForThisSession(m) {
|
|
72
|
+
return m.addressed === true || m.inSentThread === true;
|
|
73
|
+
}
|
|
74
|
+
/** A message goes out at once when it asks for something, is addressed to this session, answers its thread or already waited; account and broadcast info is digest material. */
|
|
69
75
|
export function isImmediate(m, now, digestMinutes) {
|
|
70
76
|
if (digestMinutes <= 0)
|
|
71
77
|
return true;
|
|
72
78
|
if (m.kind === 'request' || m.kind === 'error')
|
|
73
79
|
return true;
|
|
80
|
+
if (isForThisSession(m))
|
|
81
|
+
return true;
|
|
74
82
|
if (waitedMinutes(m.createdAt, now) * 60_000 >= IMMEDIATE_AFTER_MS)
|
|
75
83
|
return true;
|
|
76
84
|
return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
|
|
@@ -96,6 +104,11 @@ export function renderEvent(m, now) {
|
|
|
96
104
|
},
|
|
97
105
|
};
|
|
98
106
|
}
|
|
107
|
+
/** ORB-2263 - the scope hint names the project of this checkout in its CLI call when the environment carries one. */
|
|
108
|
+
export function scopeHintWithKey(content, rawKey) {
|
|
109
|
+
const key = rawKey?.trim().toUpperCase();
|
|
110
|
+
return key && /^[A-Z][A-Z0-9_]{0,63}$/.test(key) ? content.replaceAll('<KEY>', key) : content;
|
|
111
|
+
}
|
|
99
112
|
export function renderDigest(batch, now) {
|
|
100
113
|
const lines = [`Inbox digest: ${batch.length} info/complete message(s) since the last event. Ack the ones you handled with orboto_messages { ackIds }.`];
|
|
101
114
|
for (const m of batch) {
|
|
@@ -119,6 +132,7 @@ export class InboxChannel {
|
|
|
119
132
|
seen = new Set();
|
|
120
133
|
seenOrder = [];
|
|
121
134
|
batch = [];
|
|
135
|
+
scopeHintShown = false;
|
|
122
136
|
/** Counters an operator can read from the log; tests read them directly. */
|
|
123
137
|
stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0, notices: 0 };
|
|
124
138
|
/** ORB-2209 - what the server said on the last connect. */
|
|
@@ -172,10 +186,18 @@ export class InboxChannel {
|
|
|
172
186
|
this.digestTimer = setTimeout(() => { this.digestTimer = null; void this.flushDigest(); }, this.digestMinutes * 60_000);
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
|
-
/** ORB-2151 - a server notice
|
|
189
|
+
/** ORB-2151 - a server notice is emitted and never becomes the replay anchor; the scope hint only once per process (ORB-2263). */
|
|
176
190
|
async deliverNotice(notice, content) {
|
|
177
191
|
if (!content)
|
|
178
192
|
return;
|
|
193
|
+
if (notice === DECLARE_SCOPE_NOTICE) {
|
|
194
|
+
if (this.scopeHintShown)
|
|
195
|
+
return;
|
|
196
|
+
this.scopeHintShown = true;
|
|
197
|
+
this.stats.notices += 1;
|
|
198
|
+
await this.emit({ content: scopeHintWithKey(content, this.opts.projectKey ?? process.env.ORBOTO_PROJECT_KEY), meta: { kind: 'notice', notice } });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
179
201
|
this.stats.notices += 1;
|
|
180
202
|
if (notice === RESTART_REQUESTED_NOTICE) {
|
|
181
203
|
await this.emit({ content: this.takeRestartRequest(content), meta: { kind: 'notice', notice } });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
7
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
8
|
-
import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
|
|
8
|
+
import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent, scopeHintWithKey } from './inbox-channel.js';
|
|
9
9
|
import { buildOrbotoMcpServer } from './server.js';
|
|
10
10
|
import { _forgetDeclaredScopes, rememberDeclaredScope, rememberedScope } from './session-scope-memory.js';
|
|
11
11
|
function mockMcp() {
|
|
@@ -49,6 +49,14 @@ describe('renderEvent / isImmediate', () => {
|
|
|
49
49
|
expect(isImmediate(msg({ kind: 'info', payload: { message: 'old' }, createdAt: '2026-09-18 09:50:00+00' }), NOW, 15)).toBe(true);
|
|
50
50
|
expect(isImmediate(msg({ kind: 'info', payload: { message: 'pushed abc' } }), NOW, 0)).toBe(true);
|
|
51
51
|
});
|
|
52
|
+
it('ORB-2265 - info flagged addressed or in a sent thread wakes at once; account and broadcast info stays digest material', () => {
|
|
53
|
+
const info = (over = {}) => msg({ kind: 'info', payload: { message: 'z440-2 is off' }, ...over });
|
|
54
|
+
expect(isImmediate(info({ addressed: true }), NOW, 15)).toBe(true);
|
|
55
|
+
expect(isImmediate(info({ kind: 'complete', payload: null, addressed: true }), NOW, 15)).toBe(true);
|
|
56
|
+
expect(isImmediate(info({ threadId: 't1', inSentThread: true }), NOW, 15)).toBe(true);
|
|
57
|
+
expect(isImmediate(info(), NOW, 15)).toBe(false);
|
|
58
|
+
expect(isImmediate(info({ threadId: 't1' }), NOW, 15)).toBe(false);
|
|
59
|
+
});
|
|
52
60
|
it('reads the digest window from the environment with a safe default', () => {
|
|
53
61
|
expect(digestMinutesFromEnv(undefined)).toBe(15);
|
|
54
62
|
expect(digestMinutesFromEnv('0')).toBe(0);
|
|
@@ -58,6 +66,25 @@ describe('renderEvent / isImmediate', () => {
|
|
|
58
66
|
});
|
|
59
67
|
});
|
|
60
68
|
describe('InboxChannel', () => {
|
|
69
|
+
it('ORB-2265 - a connected channel wakes at once for info addressed to its session and for a reply flagged as in its sent thread, and digests the account info', async () => {
|
|
70
|
+
const { mcp, notification } = mockMcp();
|
|
71
|
+
const mine = 'bbbbbbbb-0000-4000-8000-000000000000';
|
|
72
|
+
const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, log: () => { }, digestMinutes: 15 });
|
|
73
|
+
channel.applySession({ id: mine, scope: null, resumed: false, scopeRestored: false, reconnects: 0, lastReconnectAt: null });
|
|
74
|
+
const noSession = { email: 'claude@orboto.io', sessionId: null, label: 'claude@orboto.io' };
|
|
75
|
+
const base = { kind: 'info', payload: { message: 'z440-2 is off' }, createdAt: '2026-09-18 09:59:50+00' };
|
|
76
|
+
await channel.deliver(msg({ ...base, id: 'addr', addressed: true }));
|
|
77
|
+
await channel.deliver(msg({ ...base, id: 'thread', threadId: 't1', inSentThread: true, to: noSession }));
|
|
78
|
+
await channel.deliver(msg({ ...base, id: 'account', to: noSession }));
|
|
79
|
+
await channel.deliver(msg({ ...base, id: 'stale-thread', threadId: 't0', to: noSession }));
|
|
80
|
+
expect(notification).toHaveBeenCalledTimes(2);
|
|
81
|
+
expect(notification.mock.calls.map((c) => c[0].params.meta.id)).toEqual(['addr', 'thread']);
|
|
82
|
+
expect(channel.stats).toMatchObject({ delivered: 2, digested: 2 });
|
|
83
|
+
await vi.advanceTimersByTimeAsync(15 * 60_000);
|
|
84
|
+
expect(notification).toHaveBeenCalledTimes(3);
|
|
85
|
+
expect(notification.mock.calls[2][0].params.meta).toMatchObject({ kind: 'digest', count: '2' });
|
|
86
|
+
channel.close();
|
|
87
|
+
});
|
|
61
88
|
it('turns each streamed message into one channel notification and batches info into a digest', async () => {
|
|
62
89
|
const { mcp, notification } = mockMcp();
|
|
63
90
|
const fetchFn = vi.fn().mockResolvedValueOnce(sse([
|
|
@@ -162,6 +189,27 @@ describe('InboxChannel', () => {
|
|
|
162
189
|
expect(String(fetchFn.mock.calls[1][0])).toContain('since=m1');
|
|
163
190
|
channel.close();
|
|
164
191
|
});
|
|
192
|
+
it('ORB-2263 - the scope hint reaches the session once per process, with the checkout project in the CLI call', async () => {
|
|
193
|
+
const { mcp, notification } = mockMcp();
|
|
194
|
+
const hint = { notice: 'declare_scope', sessionId: 'cccccccc-0000-4000-8000-000000000000', content: 'declare your scope through the CLI in the shell: orboto session-start --role worker --scope-projects <KEY> - never through the claude.ai connector' };
|
|
195
|
+
const fetchFn = vi.fn()
|
|
196
|
+
.mockResolvedValueOnce(sse([hint]))
|
|
197
|
+
.mockResolvedValueOnce(sse([hint]))
|
|
198
|
+
.mockImplementation(() => new Promise(() => { }));
|
|
199
|
+
const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-once', mcp, fetchFn, log: () => { }, digestMinutes: 0, projectKey: 'acme' });
|
|
200
|
+
channel.start();
|
|
201
|
+
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
|
|
202
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
203
|
+
await vi.waitFor(() => expect(fetchFn.mock.calls.length).toBeGreaterThanOrEqual(3));
|
|
204
|
+
expect(notification).toHaveBeenCalledTimes(1);
|
|
205
|
+
const sent = notification.mock.calls[0][0];
|
|
206
|
+
expect(sent.params.content).toContain('orboto session-start --role worker --scope-projects ACME');
|
|
207
|
+
expect(sent.params.content).not.toContain('<KEY>');
|
|
208
|
+
expect(channel.stats.notices).toBe(1);
|
|
209
|
+
expect(scopeHintWithKey('x <KEY>', undefined)).toBe('x <KEY>');
|
|
210
|
+
expect(scopeHintWithKey('x <KEY>', 'bad key!')).toBe('x <KEY>');
|
|
211
|
+
channel.close();
|
|
212
|
+
});
|
|
165
213
|
it('ORB-2181 - a restart_requested notice becomes the supervisor request file and says so', async () => {
|
|
166
214
|
const { mcp, notification } = mockMcp();
|
|
167
215
|
const writeRestart = vi.fn().mockReturnValue({ written: true, sessionId: 'sess-1', path: '/home/a/.orboto/claude/sess-1/restart.json', detail: 'ok' });
|
package/dist/index.js
CHANGED
|
@@ -78,7 +78,10 @@ async function main() {
|
|
|
78
78
|
beat();
|
|
79
79
|
const beats = setInterval(beat, 60_000);
|
|
80
80
|
beats.unref?.();
|
|
81
|
-
|
|
81
|
+
const cleanup = () => { clearInterval(beats); inbox.close(); };
|
|
82
|
+
stdio.onclose = cleanup;
|
|
83
|
+
const { endProbeSessionOnStdinEnd } = await import('./probe-session.js');
|
|
84
|
+
endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code) });
|
|
82
85
|
}
|
|
83
86
|
return;
|
|
84
87
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Must stay equal to ProbeSessionEnv in cli/internal/cmd/connect_probe.go (drift test). */
|
|
2
|
+
export const PROBE_SESSION_ENV = 'ORBOTO_PROBE_SESSION';
|
|
3
|
+
/** Answers false outside a probe; inside one, ends the session for good and exits once stdin ends. */
|
|
4
|
+
export function endProbeSessionOnStdinEnd(opts) {
|
|
5
|
+
if (opts.env[PROBE_SESSION_ENV] !== '1')
|
|
6
|
+
return false;
|
|
7
|
+
let ended = false;
|
|
8
|
+
const end = () => {
|
|
9
|
+
if (ended)
|
|
10
|
+
return;
|
|
11
|
+
ended = true;
|
|
12
|
+
opts.cleanup();
|
|
13
|
+
void opts.client.post('/v1/agent/end-session', {}, { instanceToken: opts.instanceToken })
|
|
14
|
+
.catch(() => undefined)
|
|
15
|
+
.finally(() => opts.exit(0));
|
|
16
|
+
};
|
|
17
|
+
opts.stdin.once('end', end);
|
|
18
|
+
opts.stdin.once('close', end);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** ORB-2263 - the probe proxy ends its own session on stdin end, a real session never does. */
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { endProbeSessionOnStdinEnd, PROBE_SESSION_ENV } from './probe-session.js';
|
|
6
|
+
function harness(env) {
|
|
7
|
+
const stdin = new EventEmitter();
|
|
8
|
+
const calls = [];
|
|
9
|
+
const client = { post: async (path, body, opts) => { calls.push({ path, body, instanceToken: opts?.instanceToken }); return undefined; } };
|
|
10
|
+
const cleanup = vi.fn();
|
|
11
|
+
const exit = vi.fn();
|
|
12
|
+
const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit });
|
|
13
|
+
return { stdin, calls, cleanup, exit, armed };
|
|
14
|
+
}
|
|
15
|
+
describe('ORB-2263 - probe session end', () => {
|
|
16
|
+
it('names the variable the CLI sets for the probe child', () => {
|
|
17
|
+
const go = readFileSync(new URL('../../../cli/internal/cmd/connect_probe.go', import.meta.url), 'utf8');
|
|
18
|
+
expect(go).toContain(`const ProbeSessionEnv = "${PROBE_SESSION_ENV}"`);
|
|
19
|
+
});
|
|
20
|
+
it('ends the session for good and exits when the probe stdin ends', async () => {
|
|
21
|
+
const h = harness({ [PROBE_SESSION_ENV]: '1' });
|
|
22
|
+
expect(h.armed).toBe(true);
|
|
23
|
+
h.stdin.emit('end');
|
|
24
|
+
h.stdin.emit('close');
|
|
25
|
+
await vi.waitFor(() => expect(h.exit).toHaveBeenCalledWith(0));
|
|
26
|
+
expect(h.cleanup).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(h.calls).toEqual([{ path: '/v1/agent/end-session', body: {}, instanceToken: 'agent-0123456789ab' }]);
|
|
28
|
+
});
|
|
29
|
+
it('leaves a real session alone so a resume keeps its row', () => {
|
|
30
|
+
const h = harness({});
|
|
31
|
+
expect(h.armed).toBe(false);
|
|
32
|
+
h.stdin.emit('end');
|
|
33
|
+
expect(h.calls).toEqual([]);
|
|
34
|
+
expect(h.exit).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -82,9 +82,11 @@ export function makeAgentPresenceHandler(client) {
|
|
|
82
82
|
};
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
+
/** ORB-2263 - mirror of AgentNotifyRoutedToSchema in @orboto/shared-schema (drift test in agent-coordination.test.ts). */
|
|
86
|
+
export const NOTIFY_ROUTED_TO = ['session', 'scope', 'account'];
|
|
85
87
|
export const agentNotifyToolConfig = {
|
|
86
88
|
title: 'Notify another agent / user',
|
|
87
|
-
description: 'Message a user (bot or human) by email; lands in their inbox (orboto_messages), for humans in-app. kind: request|error = work inside the scope the recipient holds (request needs project or toSessionRef + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136).',
|
|
89
|
+
description: 'Message a user (bot or human) by email; lands in their inbox (orboto_messages), for humans in-app. kind: request|error = work inside the scope the recipient holds (request needs project or toSessionRef + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136). The answer names routedTo (session, scope or account); routingNote "no session declared KEY" means nothing wakes - escalate instead of assuming delivery (ORB-2263).',
|
|
88
90
|
inputSchema: z.object({
|
|
89
91
|
targetEmail: z.string().email(),
|
|
90
92
|
kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
|
|
@@ -92,14 +94,16 @@ export const agentNotifyToolConfig = {
|
|
|
92
94
|
outcome: z.string().min(10).max(1000).optional(),
|
|
93
95
|
payload: z.record(z.string(), z.unknown()).optional(),
|
|
94
96
|
threadId: z.string().uuid().optional(),
|
|
95
|
-
project: z.string().min(1).max(64).optional().describe('Project key or UUID
|
|
96
|
-
senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref
|
|
97
|
+
project: z.string().min(1).max(64).optional().describe('Project key or UUID.'),
|
|
98
|
+
senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref; default: this MCP session.'),
|
|
97
99
|
toSessionRef: z.string().min(1).max(200).optional(),
|
|
98
100
|
}).shape,
|
|
99
101
|
outputSchema: z.object({
|
|
100
102
|
ok: z.literal(true),
|
|
101
103
|
messageId: z.string().uuid(),
|
|
102
104
|
toSessionId: z.string().uuid().nullable(),
|
|
105
|
+
routedTo: z.enum(NOTIFY_ROUTED_TO),
|
|
106
|
+
routingNote: z.string().optional(),
|
|
103
107
|
}).shape,
|
|
104
108
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
105
109
|
};
|
|
@@ -146,9 +150,11 @@ export function makeAgentNotifyHandler(client) {
|
|
|
146
150
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
147
151
|
const res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
148
152
|
const target = res.toSessionId ? `${args.targetEmail} session ${res.toSessionId.slice(0, 8)}` : args.targetEmail;
|
|
153
|
+
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
154
|
+
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
149
155
|
return {
|
|
150
|
-
content: [{ type: 'text', text: `notified ${target} (message ${res.messageId}
|
|
151
|
-
structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null },
|
|
156
|
+
content: [{ type: 'text', text: `notified ${target} (message ${res.messageId}, routed to ${routedTo}${note})` }],
|
|
157
|
+
structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo, ...(res.routingNote ? { routingNote: res.routingNote } : {}) },
|
|
152
158
|
};
|
|
153
159
|
};
|
|
154
160
|
}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* agent-sessions.test.ts.
|
|
8
8
|
*/
|
|
9
9
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
10
11
|
import { OrbotoClient } from '../orboto-client.js';
|
|
11
|
-
import { makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
|
|
12
|
+
import { NOTIFY_ROUTED_TO, agentNotifyToolConfig, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
|
|
12
13
|
import { makeAgentMessagesHandler } from './agent-messages.js';
|
|
13
14
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
14
15
|
function stub(responses) {
|
|
@@ -180,6 +181,27 @@ describe('orboto_agent_notify', () => {
|
|
|
180
181
|
expect(capturedBody[1].senderRef).toBe('runner:custom');
|
|
181
182
|
});
|
|
182
183
|
});
|
|
184
|
+
describe('orboto_agent_notify routing (ORB-2263)', () => {
|
|
185
|
+
it('mirrors the routedTo values of the shared schema', () => {
|
|
186
|
+
const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-kinds.ts', import.meta.url), 'utf8');
|
|
187
|
+
const match = shared.match(/AgentNotifyRoutedToSchema = z\.enum\(\[([^\]]+)\]\)/);
|
|
188
|
+
expect(match).not.toBeNull();
|
|
189
|
+
const values = match[1].split(',').map((v) => v.trim().replace(/'/g, ''));
|
|
190
|
+
expect(values).toEqual([...NOTIFY_ROUTED_TO]);
|
|
191
|
+
expect(agentNotifyToolConfig.description).toContain('routedTo');
|
|
192
|
+
expect(agentNotifyToolConfig.description).toContain('no session declared KEY');
|
|
193
|
+
});
|
|
194
|
+
it('answers routedTo account with the routing note so the sender escalates', async () => {
|
|
195
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
196
|
+
ok: true, status: 200, statusText: 'OK',
|
|
197
|
+
json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: null, routedTo: 'account', routingNote: 'no session declared HIVE - the message waits in the account inbox and wakes no session; escalate or address a session instead of assuming delivery' }),
|
|
198
|
+
text: async () => '',
|
|
199
|
+
}));
|
|
200
|
+
const result = await makeAgentNotifyHandler(client)({ targetEmail: 'bot@example.org', kind: 'request', subject: 'build HIVE-720', outcome: 'HIVE-720 is built and in review', project: 'HIVE' });
|
|
201
|
+
expect(result.structuredContent).toMatchObject({ routedTo: 'account', routingNote: expect.stringContaining('no session declared HIVE') });
|
|
202
|
+
expect(result.content[0].text).toContain('routed to account - no session declared HIVE');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
183
205
|
describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
|
|
184
206
|
it('fetches with excludeRef = the session ref by default; includeOwnSends drops the filter', async () => {
|
|
185
207
|
const urls = [];
|
package/dist/tools/knowledge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** ORB-2225 - the wiki as the agents' knowledge base: ask it first, file durable facts in it. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { mcpInstanceToken } from './shared.js';
|
|
4
|
+
import { droppedLines } from './wiki.js';
|
|
4
5
|
export const knowledgeAskToolConfig = {
|
|
5
6
|
title: 'Ask the wiki',
|
|
6
7
|
description: 'Ask your session\'s project wikis and the Operations wiki before asking a person; cite docs by key.',
|
|
@@ -30,6 +31,6 @@ export const knowledgeAddToolConfig = {
|
|
|
30
31
|
export function makeKnowledgeAddHandler(client) {
|
|
31
32
|
return async (input, extra) => {
|
|
32
33
|
const res = await client.post('/knowledge/add', input, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
33
|
-
return { content: [{ type: 'text', text: res.text }], structuredContent: res };
|
|
34
|
+
return { content: [{ type: 'text', text: `${res.text}${droppedLines(res.droppedOps ?? [])}` }], structuredContent: res };
|
|
34
35
|
};
|
|
35
36
|
}
|
|
@@ -26,6 +26,14 @@ describe('ORB-2225 orboto_knowledge_ask and orboto_knowledge_add', () => {
|
|
|
26
26
|
expect(textOf(result)).toContain('ACME-D7');
|
|
27
27
|
expect(result.structuredContent).toMatchObject({ action: 'recorded' });
|
|
28
28
|
});
|
|
29
|
+
it('files a fact and lists the ops the wiki agent dropped, as wiki record does', async () => {
|
|
30
|
+
const droppedOps = [{ title: 'Runner', target: 'ACME-D99', reason: 'unknown_target' }];
|
|
31
|
+
const post = vi.fn().mockResolvedValue({ action: 'recorded', text: 'Recorded in the ACME-S1 wiki: ACME-D7 "Build host". Cite it by key.', pages: [], source: null, droppedOps });
|
|
32
|
+
const result = await makeKnowledgeAddHandler({ post })({ title: 'Build host', content: 'build.example.org', projectKey: 'ACME' });
|
|
33
|
+
expect(textOf(result)).toContain('Ops dropped: 1');
|
|
34
|
+
expect(textOf(result)).toContain('- Runner (target ACME-D99): unknown_target');
|
|
35
|
+
expect(result.structuredContent).toMatchObject({ droppedOps });
|
|
36
|
+
});
|
|
29
37
|
it('both tools carry a title and annotations; ask is read-only, add is not', () => {
|
|
30
38
|
expect(knowledgeAskToolConfig.title).toBeTruthy();
|
|
31
39
|
expect(knowledgeAskToolConfig.annotations.readOnlyHint).toBe(true);
|
|
@@ -71,4 +79,9 @@ describe('ORB-2225 orboto_wiki_runs', () => {
|
|
|
71
79
|
expect(textOf(await handler({ spaceId: 'ACME-S1', action: 'retry', runId: '11111111-0000-4000-8000-000000000000' }))).toContain('ran again: applied');
|
|
72
80
|
expect(post).toHaveBeenCalledWith('/spaces/ACME-S1/llm-wiki/ingest-runs/11111111-0000-4000-8000-000000000000/retry', {});
|
|
73
81
|
});
|
|
82
|
+
it('ORB-2226 - a run that left planner ops out says how many', async () => {
|
|
83
|
+
const get = vi.fn().mockResolvedValue({ runs: [{ id: 'r2', sourceKey: 'ACME-D10', sourceTitle: 'Deploy notes', status: 'applied', error: null, droppedOps: [{ title: 'Deploy', target: 'ACME-D99', reason: 'unknown_target' }], createdAt: '2026-09-27T09:00:00Z' }] });
|
|
84
|
+
const handler = makeWikiRunsHandler({ get, post: vi.fn() });
|
|
85
|
+
expect(textOf(await handler({ spaceId: 'ACME-S1' }))).toContain('r2 applied ACME-D10 "Deploy notes" 2026-09-27T09:00:00Z (ops dropped: 1)');
|
|
86
|
+
});
|
|
74
87
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export const secretScanToolConfig = {
|
|
6
6
|
title: 'Secret scan',
|
|
7
|
-
description: 'Admin sweep for stored secret-shaped values (ORB-2229); see skill reference admin.md "Secret scan".',
|
|
7
|
+
description: 'Admin sweep for stored secret-shaped values (ORB-2229); see skill reference admin.md "Secret scan". Each offer prints a findingId (the credential kind it would become); the move into the vault is not a tool: send that id to POST /admin/content/secret-scan/move through orboto_api_call, or run `orboto admin secret-scan move <findingId>` in the CLI.',
|
|
8
8
|
inputSchema: z.object({
|
|
9
9
|
redact: z.boolean().optional().describe('Replace found values, purge history (default false).'),
|
|
10
10
|
limit: z.number().int().min(1).max(1000).optional().describe('Max hits (default 200).'),
|
|
@@ -31,6 +31,9 @@ export function makeSecretScanHandler(client) {
|
|
|
31
31
|
for (const f of hit.findings) {
|
|
32
32
|
lines.push(` ${f.class} in ${hit.field} at line ${f.line}, column ${f.column} (${f.preview}, ${f.length} chars)`);
|
|
33
33
|
}
|
|
34
|
+
for (const m of hit.moves ?? []) {
|
|
35
|
+
lines.push(` move as ${m.kind}: findingId ${m.findingId} (post /admin/content/secret-scan/move with a key)`);
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
38
|
lines.push(report.redacted > 0
|
|
36
39
|
? `Redacted ${report.redacted} rows; rotate every credential listed above.`
|
|
@@ -46,7 +49,7 @@ export function makeSecretScanHandler(client) {
|
|
|
46
49
|
hits: report.hits.map((h) => ({
|
|
47
50
|
entity: h.entity, id: h.id, label: h.label, field: h.field,
|
|
48
51
|
projectKey: h.projectKey, spaceVisibility: h.spaceVisibility, webUrl: h.webUrl,
|
|
49
|
-
classes: h.findings.map((f) => f.class), redacted: h.redacted,
|
|
52
|
+
classes: h.findings.map((f) => f.class), redacted: h.redacted, moves: h.moves ?? [],
|
|
50
53
|
})),
|
|
51
54
|
},
|
|
52
55
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** ORB-2229, ORB-2261 - orboto_secret_scan prints each hit masked and the finding id of every move offer. */
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
4
|
+
import { makeSecretScanHandler } from './secret-scan.js';
|
|
5
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
6
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.org', apiKey: 'orb_x' });
|
|
7
|
+
describe('orboto_secret_scan', () => {
|
|
8
|
+
it('lists the move offers with their finding ids and never a value', async () => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
11
|
+
calls.push({ url: url.toString(), body: init?.body ? JSON.parse(init.body) : undefined });
|
|
12
|
+
return {
|
|
13
|
+
ok: true, status: 200, statusText: 'OK', text: async () => '',
|
|
14
|
+
json: async () => ({
|
|
15
|
+
scannedAt: '2026-09-29T10:00:00.000Z', scanned: { doc: 1 }, redacted: 0, nextCursor: null, truncated: false,
|
|
16
|
+
hits: [{
|
|
17
|
+
entity: 'doc', id: 'd1', label: 'ACME-D38', field: 'content', projectKey: 'ACME', spaceVisibility: 'workspace', webUrl: null,
|
|
18
|
+
findings: [{ class: 'labelled_value', line: 3, column: 11, preview: 'Hk***', length: 12 }], redacted: false,
|
|
19
|
+
moves: [{ findingId: 'WyJkb2MiXQ', kind: 'login', findingIndexes: [0] }],
|
|
20
|
+
}],
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
const res = await makeSecretScanHandler(client)({});
|
|
25
|
+
expect(calls[0].url).toContain('/admin/content/secret-scan');
|
|
26
|
+
const text = res.content[0].text;
|
|
27
|
+
expect(text).toContain('move as login: findingId WyJkb2MiXQ');
|
|
28
|
+
expect(res.structuredContent).toMatchObject({ hits: [{ moves: [{ findingId: 'WyJkb2MiXQ', kind: 'login' }] }] });
|
|
29
|
+
});
|
|
30
|
+
});
|
package/dist/tools/wiki.js
CHANGED
|
@@ -69,9 +69,15 @@ export function makeWikiLintHandler(client) {
|
|
|
69
69
|
return text(`Lint complete: ${res.issues.length} open issue(s), ${res.resolved} auto-resolved.\n${lines.join('\n')}`, { issues: res.issues, detected: res.detected, resolved: res.resolved });
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
/** ORB-2226 - planner ops whose target page matched no docId, doc key or title. */
|
|
73
|
+
export function droppedLines(dropped) {
|
|
74
|
+
if (dropped.length === 0)
|
|
75
|
+
return '';
|
|
76
|
+
return `\nOps dropped: ${dropped.length}\n${dropped.map((d) => `- ${d.title}${d.target ? ` (target ${d.target})` : ''}: ${d.reason}`).join('\n')}`;
|
|
77
|
+
}
|
|
72
78
|
export const wikiPlanUpdateToolConfig = {
|
|
73
79
|
title: 'Plan a wiki edit (dry-run, no writes)',
|
|
74
|
-
description: 'Turn a natural-language instruction into page operations (create/patch/append) WITHOUT writing anything. Returns a planId valid for 15 minutes plus the proposed ops. Review the ops, then call orboto_wiki_apply_plan to commit. Wraps POST /spaces/:id/docs/plan-update.',
|
|
80
|
+
description: 'Turn a natural-language instruction into page operations (create/patch/append) WITHOUT writing anything. Returns a planId valid for 15 minutes plus the proposed ops, and droppedOps: ops whose target page matched no docId, doc key or title. Review the ops, then call orboto_wiki_apply_plan to commit. Wraps POST /spaces/:id/docs/plan-update.',
|
|
75
81
|
inputSchema: z.object({
|
|
76
82
|
spaceId: z.string().uuid().describe('The wiki space to edit.'),
|
|
77
83
|
instruction: z.string().min(1).max(4000).describe('What to change, in plain language.'),
|
|
@@ -86,7 +92,8 @@ export function makeWikiPlanUpdateHandler(client) {
|
|
|
86
92
|
body.sourceDocId = input.sourceDocId;
|
|
87
93
|
const res = await client.post(`/spaces/${input.spaceId}/docs/plan-update`, body);
|
|
88
94
|
const ops = res.ops.map((o, i) => `${i + 1}. ${o.op} ${o.title ?? ''} - ${o.summary}`).join('\n');
|
|
89
|
-
|
|
95
|
+
const droppedOps = res.droppedOps ?? [];
|
|
96
|
+
return text(`Plan ${res.planId} (expires ${res.expiresAt}):\n${ops}${droppedLines(droppedOps)}\n\nApply with orboto_wiki_apply_plan(planId).`, { planId: res.planId, ops: res.ops, droppedOps, expiresAt: res.expiresAt });
|
|
90
97
|
};
|
|
91
98
|
}
|
|
92
99
|
export const wikiApplyPlanToolConfig = {
|
|
@@ -121,7 +128,8 @@ export function makeWikiRecordHandler(client) {
|
|
|
121
128
|
planBody.sourceDocId = input.sourceDocId;
|
|
122
129
|
const plan = await client.post(`/spaces/${input.spaceId}/docs/plan-update`, planBody);
|
|
123
130
|
const applied = await client.post(`/spaces/${input.spaceId}/docs/apply-plan`, { planId: plan.planId });
|
|
124
|
-
|
|
131
|
+
const droppedOps = plan.droppedOps ?? [];
|
|
132
|
+
return text(`Recorded: ${applied.touchedDocs.length} page(s) updated.${droppedLines(droppedOps)}`, { planId: plan.planId, touchedDocs: applied.touchedDocs, droppedOps });
|
|
125
133
|
};
|
|
126
134
|
}
|
|
127
135
|
export const wikiAppendSectionToolConfig = {
|
|
@@ -196,7 +204,7 @@ export function makeWikiRunsHandler(client) {
|
|
|
196
204
|
}
|
|
197
205
|
const qs = input.status ? `?status=${input.status}` : '';
|
|
198
206
|
const res = await client.get(`/spaces/${encodeURIComponent(input.spaceId)}/llm-wiki/ingest-runs${qs}`);
|
|
199
|
-
const lines = res.runs.map((r) => `- ${r.id} ${r.status} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.error ? ` - ${r.error}` : ''}`);
|
|
207
|
+
const lines = res.runs.map((r) => `- ${r.id} ${r.status} ${r.sourceKey ?? ''} "${r.sourceTitle}" ${r.createdAt}${r.droppedOps?.length ? ` (ops dropped: ${r.droppedOps.length})` : ''}${r.error ? ` - ${r.error}` : ''}`);
|
|
200
208
|
return text(lines.length ? lines.join('\n') : 'No ingest runs.', res);
|
|
201
209
|
};
|
|
202
210
|
}
|
package/dist/tools/wiki.test.js
CHANGED
|
@@ -57,6 +57,13 @@ describe('orboto_wiki_plan_update + apply_plan', () => {
|
|
|
57
57
|
expect(calls[0].url).toBe(`https://orboto.example.com/spaces/${SPACE}/docs/plan-update`);
|
|
58
58
|
expect(res.content[0].text).toContain('Plan p1');
|
|
59
59
|
});
|
|
60
|
+
it('ORB-2226 - plan-update names the ops the planner target could not be resolved for', async () => {
|
|
61
|
+
stubJSON([{ json: { planId: 'p1', ops: [], droppedOps: [{ title: 'Runbook', target: 'ghost page', reason: 'unknown_target' }], expiresAt: '2026-01-01T00:15:00Z' } }]);
|
|
62
|
+
const res = await makeWikiPlanUpdateHandler(client)({ spaceId: SPACE, instruction: 'update the runbook' });
|
|
63
|
+
const out = res.content[0].text;
|
|
64
|
+
expect(out).toContain('Ops dropped: 1');
|
|
65
|
+
expect(out).toContain('- Runbook (target ghost page): unknown_target');
|
|
66
|
+
});
|
|
60
67
|
it('apply-plan reports touched docs', async () => {
|
|
61
68
|
stubJSON([{ json: { touchedDocs: [DOC] } }]);
|
|
62
69
|
const res = await makeWikiApplyPlanHandler(client)({ spaceId: SPACE, planId: 'p1' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.211.1",
|
|
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",
|