@orboto/mcp 0.215.1 → 0.217.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.
@@ -0,0 +1,33 @@
1
+ /** ORB-2467 - the wake channel shows count and total size of agent mail attachments, never names; the tail calls are labelled. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { attachmentLine, renderDigest, renderEvent } from './inbox-channel.js';
4
+ import { metricsToolName } from './tail-labels.js';
5
+ const NOW = Date.parse('2026-10-04T10:00:00Z');
6
+ const ID = '6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f';
7
+ const msg = (over = {}) => ({
8
+ id: ID, kind: 'info', subject: 'build output', createdAt: '2026-10-04T09:59:00Z', from: { label: 'a@x.test' }, ...over,
9
+ });
10
+ describe('ORB-2467 - attachments on the wake channel', () => {
11
+ it('an event names count and total size and the download command, a digest the summary', () => {
12
+ const carried = msg({ attachments: { count: 1, label: '1 archive, 12.4 MB' }, payload: { message: 'see attached' } });
13
+ const event = renderEvent(carried, NOW);
14
+ expect(event.content).toContain(`attachments: 1 archive, 12.4 MB (orboto messages --download ${ID})`);
15
+ expect(renderDigest([carried], NOW).content).toContain('| attachments: 1 archive, 12.4 MB');
16
+ });
17
+ it('a message without attachments renders as before', () => {
18
+ expect(attachmentLine(msg())).toBe('');
19
+ expect(renderEvent(msg(), NOW).content).not.toContain('attachments');
20
+ });
21
+ });
22
+ describe('ORB-2467 - tail labels', () => {
23
+ const call = (method, path) => metricsToolName('orboto_api_call', { method, path });
24
+ it('labels the list, the signed URL and the operator panel', () => {
25
+ expect(call('GET', `/v1/agent/messages/${ID}/attachments`)).toBe('orboto_agent_message_attachments');
26
+ expect(call('GET', `/v1/agent/messages/${ID}/attachments/${ID}/url`)).toBe('orboto_agent_message_attachments');
27
+ expect(call('GET', '/admin/agents/transfers')).toBe('orboto_agent_transfers');
28
+ expect(call('DELETE', `/admin/agents/transfers/${ID}`)).toBe('orboto_agent_transfers');
29
+ });
30
+ it('leaves the multipart upload unlabelled: it needs the CLI', () => {
31
+ expect(call('POST', '/v1/agent/messages/attachments')).toBe('orboto_api_call');
32
+ });
33
+ });
@@ -5,7 +5,7 @@
5
5
  * nothing - the ledger then says "unknown", never an estimate.
6
6
  */
7
7
  import { readdirSync, readFileSync } from 'node:fs';
8
- import { homedir } from 'node:os';
8
+ import { homedir, hostname } from 'node:os';
9
9
  import path from 'node:path';
10
10
  export const CLAUDE_CONTEXT_MAX_AGE_MS = 10 * 60_000;
11
11
  export function claudeContextDir() {
@@ -46,3 +46,8 @@ export function readClaudeContext(dir, now = Date.now(), root = claudeContextDir
46
46
  }
47
47
  return fresh.length === 1 ? fresh[0] : null;
48
48
  }
49
+ /** ORB-2465 - the channel heartbeat's clientInfo: the wake channel, the host `--host` filters on, and the context report. */
50
+ export function channelClientInfo(context, host = hostname()) {
51
+ const value = host.trim().toLowerCase().slice(0, 200);
52
+ return { channel: 'claude-code', ...(value ? { host: value } : {}), ...(context ?? {}) };
53
+ }
@@ -6,7 +6,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
6
6
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import path from 'node:path';
9
- import { claudeContextDir, readClaudeContext } from './claude-context.js';
9
+ import { channelClientInfo, claudeContextDir, readClaudeContext } from './claude-context.js';
10
10
  let root;
11
11
  beforeEach(() => {
12
12
  root = mkdtempSync(path.join(tmpdir(), 'orboto-ctx-'));
@@ -41,3 +41,9 @@ describe('ORB-2149 - the report directory', () => {
41
41
  expect(claudeContextDir().endsWith(path.join('.orboto', 'claude-context'))).toBe(true);
42
42
  });
43
43
  });
44
+ describe('ORB-2465 - the channel heartbeat names its host', () => {
45
+ it('sends the lower-cased host next to the channel and the context report', () => {
46
+ expect(channelClientInfo({ contextTokens: 10 }, ' Build-Host-A ')).toEqual({ channel: 'claude-code', host: 'build-host-a', contextTokens: 10 });
47
+ expect(channelClientInfo(null, '')).toEqual({ channel: 'claude-code' });
48
+ });
49
+ });
@@ -13,6 +13,10 @@ const MIN_BACKOFF_MS = 2_000;
13
13
  const MAX_BACKOFF_MS = 60_000;
14
14
  const HEARTBEAT_GRACE_MS = 90_000;
15
15
  const ASK = /\?|\bplease\b|\bbitte\b|\bneed\b|\bshare\b/i;
16
+ /** ORB-2467 - `attachments: 1 archive, 12.4 MB (orboto messages --download <id>)`, or nothing. */
17
+ export function attachmentLine(m) {
18
+ return m.attachments?.count ? `attachments: ${m.attachments.label} (orboto messages --download ${m.id})` : '';
19
+ }
16
20
  /** The instructions paragraph Claude Code delivers on connect when the channel is active. */
17
21
  export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session arrive as <channel source="orboto" id="..." kind="..." ...> events - '
18
22
  + 'peer requests, ticket-ready notices, replies, digests of info/complete mail. Treat each like an inbox message under the rules: '
@@ -94,6 +98,9 @@ export function renderEvent(m, now) {
94
98
  ];
95
99
  if (body)
96
100
  lines.push(body);
101
+ const carried = attachmentLine(m);
102
+ if (carried)
103
+ lines.push(carried);
97
104
  const waited = waitedMinutes(m.createdAt, now);
98
105
  return {
99
106
  content: lines.join('\n'),
@@ -113,7 +120,8 @@ export function renderDigest(batch, now) {
113
120
  const lines = [`Inbox digest: ${batch.length} info/complete message(s) since the last event. Ack the ones you handled with orboto_messages { ackIds }.`];
114
121
  for (const m of batch) {
115
122
  const text = firstLine(m.payload);
116
- lines.push(`- [${m.kind}] ${m.subject} | from ${m.from?.label ?? m.fromUserId ?? 'unknown'}${m.projectKey ? ` | ${m.projectKey}` : ''} | id ${m.id}${text ? ` | ${text.slice(0, 160)}` : ''}`);
123
+ const carried = m.attachments?.count ? ` | attachments: ${m.attachments.label}` : '';
124
+ lines.push(`- [${m.kind}] ${m.subject} | from ${m.from?.label ?? m.fromUserId ?? 'unknown'}${m.projectKey ? ` | ${m.projectKey}` : ''} | id ${m.id}${carried}${text ? ` | ${text.slice(0, 160)}` : ''}`);
117
125
  }
118
126
  return { content: lines.join('\n'), meta: { kind: 'digest', count: String(batch.length), waited_min: String(waitedMinutes(batch[0].createdAt, now)) } };
119
127
  }
package/dist/index.js CHANGED
@@ -71,10 +71,9 @@ async function main() {
71
71
  const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
72
72
  if (outdated)
73
73
  void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
74
- const { readClaudeContext } = await import('./claude-context.js');
74
+ const { readClaudeContext, channelClientInfo } = await import('./claude-context.js');
75
75
  const beat = () => {
76
- const context = readClaudeContext(process.cwd());
77
- preflightClient.post('/v1/agent/heartbeat', { clientInfo: { channel: 'claude-code', ...(context ?? {}) } }, { instanceToken })
76
+ preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd())) }, { instanceToken })
78
77
  .catch((err) => { console.error(`[orboto-mcp] channel heartbeat failed: ${err.message}`); });
79
78
  };
80
79
  beat();
@@ -47,6 +47,8 @@ afterEach(() => { vi.restoreAllMocks(); });
47
47
  * verdicts themselves ride the response, not the manifest.
48
48
  * ORB-2338 added no named tool; the transcript rides the escape hatch and
49
49
  * the resource.
50
+ * ORB-2465 added no named tool: the live notice rides the escape hatch
51
+ * (full = 171,194 chars, 6 free).
50
52
  *
51
53
  * @see ORB-1805, ORB-1669, ORB-1910
52
54
  */
@@ -20,6 +20,11 @@ export const TAIL_LABELS = [
20
20
  { method: 'PATCH', path: new RegExp(`^/projects/${SEG}/chat-bot/routes/${SEG}$`), label: 'orboto_chat_bot_route' },
21
21
  { method: 'DELETE', path: new RegExp(`^/projects/${SEG}/chat-bot/routes/${SEG}$`), label: 'orboto_chat_bot_route' },
22
22
  { method: 'POST', path: /^\/chat-bot\/links\/(confirm|[^/]+\/revoke)$/, label: 'orboto_chat_bot_link' },
23
+ { method: 'POST', path: /^\/v1\/agent\/broadcast\/live$/, label: 'orboto_agent_notice' },
24
+ { method: 'GET', path: new RegExp(`^/v1/agent/broadcasts/live(/${SEG})?$`), label: 'orboto_agent_notice_status' },
25
+ { method: 'GET', path: new RegExp(`^/v1/agent/messages/${SEG}/attachments(/${SEG}/url)?$`), label: 'orboto_agent_message_attachments' },
26
+ { method: 'GET', path: /^\/admin\/agents\/transfers$/, label: 'orboto_agent_transfers' },
27
+ { method: 'DELETE', path: new RegExp(`^/admin/agents/transfers/${SEG}$`), label: 'orboto_agent_transfers' },
23
28
  ];
24
29
  /** The metrics label of one tool call: the stand-in tool name for a labelled api_call, else the tool name itself. */
25
30
  export function metricsToolName(toolName, args) {
@@ -192,7 +192,7 @@ export function makeAgentNotifyHandler(client) {
192
192
  const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
193
193
  const note = res.routingNote ? ` - ${res.routingNote}` : '';
194
194
  return {
195
- content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})` }],
195
+ content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.hint ? `\nhint: ${res.hint}` : ''}` }],
196
196
  structuredContent: {
197
197
  ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
198
198
  ...(res.routingNote ? { routingNote: res.routingNote } : {}),
@@ -201,6 +201,16 @@ describe('orboto_agent_notify routing (ORB-2263)', () => {
201
201
  expect(result.structuredContent).toMatchObject({ routedTo: 'account', routingNote: expect.stringContaining('no session declared HIVE') });
202
202
  expect(result.content[0].text).toContain('routed to account - no session declared HIVE');
203
203
  });
204
+ it('ORB-2465: passes the agent-broadcast live hint on in the text, not in the output schema', async () => {
205
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
206
+ ok: true, status: 200, statusText: 'OK',
207
+ json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: null, routedTo: 'account', hint: 'account mail without --project wakes no particular session; send orboto agent-broadcast live' }),
208
+ text: async () => '',
209
+ }));
210
+ const result = await makeAgentNotifyHandler(client)({ targetEmail: 'bot@example.org', kind: 'info', subject: 'restart' });
211
+ expect(result.content[0].text).toContain('\nhint: account mail without --project');
212
+ expect(result.structuredContent).not.toHaveProperty('hint');
213
+ });
204
214
  });
205
215
  describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
206
216
  it('fetches with excludeRef = the session ref by default; includeOwnSends drops the filter', async () => {
@@ -0,0 +1,16 @@
1
+ /** ORB-2465 - the live notice reaches an MCP-only agent through the escape hatch; every call is labelled in the metrics. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { metricsToolName } from '../tail-labels.js';
4
+ const ID = '6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f';
5
+ const call = (method, path) => metricsToolName('orboto_api_call', { method, path });
6
+ describe('ORB-2465 - live notice tail labels', () => {
7
+ it('labels the send, the status and the active list', () => {
8
+ expect(call('POST', '/v1/agent/broadcast/live')).toBe('orboto_agent_notice');
9
+ expect(call('GET', `/v1/agent/broadcasts/live/${ID}`)).toBe('orboto_agent_notice_status');
10
+ expect(call('GET', '/v1/agent/broadcasts/live')).toBe('orboto_agent_notice_status');
11
+ });
12
+ it('leaves the scope broadcasts and the prompt-hook delivery unlabelled', () => {
13
+ expect(call('POST', '/v1/agent/broadcast')).toBe('orboto_api_call');
14
+ expect(call('POST', '/v1/agent/broadcasts/live/deliver')).toBe('orboto_api_call');
15
+ });
16
+ });
@@ -16,7 +16,8 @@ export function whoLine(s) {
16
16
  const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
17
17
  const channel = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
18
18
  const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
19
- return `- ${address} - ${s.email} session ${s.shortId}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}`;
19
+ const host = s.host ? `, host ${s.host}` : '';
20
+ return `- ${address} - ${s.email} session ${s.shortId}${host}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}`;
20
21
  }
21
22
  export function makeAgentWhoHandler(client) {
22
23
  return async (args = {}) => {
@@ -28,4 +28,7 @@ describe('orboto_who', () => {
28
28
  expect(agentWhoToolConfig.annotations.readOnlyHint).toBe(true);
29
29
  expect(whoLine({ ...entry, addresses: [], role: null, channel: { announced: null, online: false }, currentTicket: null })).toContain('no scope');
30
30
  });
31
+ it('ORB-2465: names the host a live notice filters on', () => {
32
+ expect(whoLine({ ...entry, host: 'build-host-a' })).toContain('session f072e1c5, host build-host-a, working');
33
+ });
31
34
  });
@@ -9,7 +9,7 @@
9
9
  * @see ORB-1605, ORB-1607, ORB-1818
10
10
  */
11
11
  import { z } from 'zod';
12
- import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken } from './shared.js';
12
+ import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost } from './shared.js';
13
13
  import { AgentSessionScopeSchema } from './agent-session-scope.js';
14
14
  import { PROCESS_OWNER, PROTECT_TEXT_META, storePayload } from '../response-budget.js';
15
15
  import { loadRequiredRules } from '../required-rules.js';
@@ -202,7 +202,8 @@ export function makeSessionStartHandler(client, opts = {}) {
202
202
  };
203
203
  }
204
204
  const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null);
205
- let registration = await client.post('/v1/agent/heartbeat', input.scope !== undefined ? { scope: input.scope } : {}, { instanceToken })
205
+ const host = localSessionHost(extra);
206
+ let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
206
207
  .then(toRegistration)
207
208
  .catch(() => null);
208
209
  if (input.scope !== undefined && registration)
@@ -14,6 +14,8 @@ import { CHANNEL_START_COMMANDS, CHANNEL_START_COMMANDS_WITHOUT_CLI } from '../i
14
14
  import { readFileSync } from 'node:fs';
15
15
  import { makeResponseExpandHandler } from './response-expand.js';
16
16
  import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
17
+ import { hostname } from 'node:os';
18
+ import { localSessionHost } from './shared.js';
17
19
  beforeEach(() => { vi.restoreAllMocks(); });
18
20
  afterEach(() => { vi.restoreAllMocks(); });
19
21
  function stubByPath(map) {
@@ -572,6 +574,21 @@ describe('ORB-2136 - session scope and ref', () => {
572
574
  await makeSessionStartHandler(client)({ scope: { role: 'worker', projectKeys: ['orb'] } }, { sessionId: 'keep1' });
573
575
  expect(rememberedScope('mcp-keep1')).toEqual({ role: 'worker', projectKeys: ['ORB'] });
574
576
  });
577
+ it('ORB-2465 - a stdio session reports its host on the heartbeat; an HTTP connection reports none', async () => {
578
+ const seen = [];
579
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
580
+ const u = new URL(url.toString());
581
+ seen.push({ path: u.pathname, body: init?.body ? JSON.parse(init.body) : undefined });
582
+ const body = u.pathname === '/v1/agent/heartbeat'
583
+ ? { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null }
584
+ : u.pathname === '/agent-instructions' ? { instructions: 'rules here', rulesHash: 'fixture' } : {};
585
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
586
+ });
587
+ await makeSessionStartHandler(client)({});
588
+ expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({ clientInfo: { host: hostname().trim().toLowerCase() } });
589
+ expect(localSessionHost({ sessionId: 'http-1' }, 'server')).toBeUndefined();
590
+ expect(localSessionHost(undefined, ' Mac-A ')).toBe('mac-a');
591
+ });
575
592
  it('without a scope the heartbeat body stays empty and the digest says no scope is declared', async () => {
576
593
  const seen = [];
577
594
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
@@ -114,6 +114,13 @@ export function checkoutInstanceToken(env = process.env, host = hostname(), cwd
114
114
  return explicit || `agent-${createHash('sha256').update(`${host}:${cwd}`).digest('hex').slice(0, 12)}`;
115
115
  }
116
116
  const MCP_PROCESS_INSTANCE = checkoutInstanceToken();
117
+ /** ORB-2465 - the machine a stdio proxy runs on, lower-cased, as `agent-broadcast live --host` matches it; an HTTP connection (per-connection session id) runs on the server, so it reports none. */
118
+ export function localSessionHost(extra, host = hostname()) {
119
+ if (extra?.sessionId)
120
+ return undefined;
121
+ const value = host.trim().toLowerCase().slice(0, 200);
122
+ return value || undefined;
123
+ }
117
124
  /** Precedence: explicit caller-supplied token > per-connection MCP session id
118
125
  * (distinct per client even on a shared HTTP server) > per-process id (stdio). */
119
126
  export function mcpInstanceToken(explicit, extra) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.215.1",
3
+ "version": "0.217.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",