@orboto/mcp 0.220.0 → 0.221.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.
@@ -49,6 +49,8 @@ afterEach(() => { vi.restoreAllMocks(); });
49
49
  * the resource.
50
50
  * ORB-2465 added no named tool: the live notice rides the escape hatch
51
51
  * (full = 171,194 chars, 6 free).
52
+ * ORB-2502 gave `orboto_messages` a `since` parameter inside the ceiling: the
53
+ * limit lost its schema default and the project hint shrank (full = 171,197).
52
54
  *
53
55
  * @see ORB-1805, ORB-1669, ORB-1910
54
56
  */
@@ -20,6 +20,17 @@ 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: 'GET', path: /^\/admin\/integrations\/lexware(\/(readiness|events|deliveries))?$/, label: 'orboto_lexware_connection' },
24
+ { method: 'POST', path: /^\/admin\/integrations\/lexware\/test$/, label: 'orboto_lexware_connection' },
25
+ { method: 'POST', path: /^\/admin\/integrations\/lexware(\/(rotate-key|subscriptions))?$/, label: 'orboto_lexware_admin' },
26
+ { method: 'PATCH', path: /^\/admin\/integrations\/lexware$/, label: 'orboto_lexware_admin' },
27
+ { method: 'DELETE', path: /^\/admin\/integrations\/lexware(\/subscriptions)?$/, label: 'orboto_lexware_admin' },
28
+ { method: 'GET', path: new RegExp(`^/customers/${SEG}/lexware-contact$`), label: 'orboto_lexware_contact' },
29
+ { method: 'PUT', path: new RegExp(`^/customers/${SEG}/lexware-contact$`), label: 'orboto_lexware_contact' },
30
+ { method: 'DELETE', path: new RegExp(`^/customers/${SEG}/lexware-contact$`), label: 'orboto_lexware_contact' },
31
+ { method: 'POST', path: new RegExp(`^/customers/${SEG}/lexware-billing-runs(/preview|/${SEG}/refresh)?$`), label: 'orboto_lexware_billing_run' },
32
+ { method: 'GET', path: new RegExp(`^/customers/${SEG}/lexware-billing-runs(/summary)?$`), label: 'orboto_lexware_billing_run' },
33
+ { method: 'GET', path: /^\/admin\/integrations\/lexware\/billing-runs$/, label: 'orboto_lexware_billing_run' },
23
34
  { method: 'POST', path: /^\/v1\/agent\/broadcast\/live$/, label: 'orboto_agent_notice' },
24
35
  { method: 'GET', path: new RegExp(`^/v1/agent/broadcasts/live(/${SEG})?$`), label: 'orboto_agent_notice_status' },
25
36
  { method: 'GET', path: new RegExp(`^/v1/agent/messages/${SEG}/attachments(/${SEG}/url)?$`), label: 'orboto_agent_message_attachments' },
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ORB-2502 - orboto_messages lists the newest 10 unread compactly with the exact count; all:true keeps every message in full.
3
+ */
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoClient } from '../orboto-client.js';
6
+ import { makeAgentMessagesHandler } from './agent-messages.js';
7
+ afterEach(() => { vi.restoreAllMocks(); });
8
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
9
+ const party = (label) => ({ userId: 'u', email: 'e', sessionId: null, role: null, label });
10
+ const message = {
11
+ id: 'm1', fromUserId: 'u1', toUserId: 'u2', fromSessionId: null, toSessionId: null, from: party('Ada'), to: party('Bot'),
12
+ kind: 'request', subject: 'review', payload: { message: 'x'.repeat(1_000) }, threadId: null, projectKey: 'ORB',
13
+ createdAt: '2026-10-01T10:00:00Z', deliveredAt: null, readAt: null, dismissed: null,
14
+ excerpt: `message: ${'x'.repeat(291)}`, payloadChars: 1_009,
15
+ };
16
+ function stubInbox(urls) {
17
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
18
+ urls.push(String(url));
19
+ return {
20
+ ok: true, status: 200, statusText: 'OK', text: async () => '',
21
+ json: async () => ({ messages: [message], sessionId: 'cbb52195-0000-4000-8000-000000000000', counts: { unread: 14, olderThan7Days: 12 } }),
22
+ };
23
+ });
24
+ }
25
+ describe('ORB-2502 - the compact inbox on MCP', () => {
26
+ it('asks for the newest 10 compactly, prints the excerpt and the count, and leaves the payload out of the structured answer', async () => {
27
+ const urls = [];
28
+ stubInbox(urls);
29
+ const result = await makeAgentMessagesHandler(client)({ since: '2026-09-30T00:00:00Z' }, { sessionId: 'abc' });
30
+ expect(urls[0]).toContain('limit=10');
31
+ expect(urls[0]).toContain('compact=true');
32
+ expect(urls[0]).toContain('since=2026-09-30T00%3A00%3A00Z');
33
+ const text = result.content[0].text;
34
+ expect(text).toContain(`\n message: ${'x'.repeat(291)}\n ... 709 more characters (all:true prints everything)`);
35
+ expect(text).toContain('14 unread, 12 older than 7 days (all, limit N, since)');
36
+ expect(text).not.toContain('payload:');
37
+ expect(result.structuredContent).toMatchObject({ messages: [{ id: 'm1', payload: null }] });
38
+ });
39
+ it('all:true prints every message in full as before, with the limit of 50', async () => {
40
+ const urls = [];
41
+ stubInbox(urls);
42
+ const result = await makeAgentMessagesHandler(client)({ all: true }, { sessionId: 'abc' });
43
+ expect(urls[0]).toContain('all=true');
44
+ expect(urls[0]).toContain('limit=50');
45
+ expect(urls[0]).not.toContain('compact');
46
+ const text = result.content[0].text;
47
+ expect(text).toContain(`payload: {"message":"${'x'.repeat(1_000)}"}`);
48
+ expect(text).not.toContain('older than 7 days');
49
+ expect(result.structuredContent).toMatchObject({ messages: [{ payload: { message: 'x'.repeat(1_000) } }] });
50
+ });
51
+ });
@@ -14,13 +14,14 @@ const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId:
14
14
  const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
15
15
  export const agentMessagesToolConfig = {
16
16
  title: 'Agent inbox and message work',
17
- description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true is the account view (ORB-2136). Before orboto_session_start only account mail is listed; register to see mail addressed to your session (ORB-2347). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
17
+ description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true is the account view with every message in full (ORB-2136). By default it lists the newest 10 unread, newest first, each as a header plus the first 300 payload characters, then the line `N unread, M older than 7 days`; limit and since (ISO time or message id) narrow it (ORB-2502). Before orboto_session_start only account mail is listed; register to see mail addressed to your session (ORB-2347). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
18
18
  inputSchema: z.object({
19
19
  messageWork: z.record(z.unknown()).optional()
20
20
  .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
21
21
  all: z.boolean().default(false),
22
- limit: z.number().int().min(1).max(200).default(50),
23
- project: z.string().min(1).max(64).optional().describe('Project key/UUID; includes unscoped mail.'),
22
+ limit: z.number().int().min(1).max(200).optional(),
23
+ since: z.string().optional(),
24
+ project: z.string().min(1).max(64).optional().describe('Key/UUID; includes unscoped mail.'),
24
25
  includeOwnSends: z.boolean().default(false),
25
26
  ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
26
27
  dismiss: z.object({
@@ -55,6 +56,16 @@ export const agentMessagesToolConfig = {
55
56
  }).shape,
56
57
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
57
58
  };
59
+ /** ORB-2502 - the compact view: the header, the first 300 payload characters indented, and how much was left out. */
60
+ function compactLines(m, header) {
61
+ const excerpt = m.excerpt ?? '';
62
+ const rest = (m.payloadChars ?? 0) - Array.from(excerpt).length;
63
+ return [
64
+ header,
65
+ ...(excerpt ? excerpt.replace(/\n+$/, '').split('\n').map((line) => ` ${line}`) : []),
66
+ ...(rest > 0 ? [` ... ${rest} more characters (all:true prints everything)`] : []),
67
+ ];
68
+ }
58
69
  export function makeAgentMessagesHandler(client) {
59
70
  return async (args, extra) => {
60
71
  if (args.messageWork) {
@@ -80,23 +91,30 @@ export function makeAgentMessagesHandler(client) {
80
91
  const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
81
92
  acked = res.acked;
82
93
  }
94
+ const compact = !args.all;
83
95
  const q = new URLSearchParams();
84
96
  if (args.all)
85
97
  q.set('all', 'true');
86
- if (args.limit)
87
- q.set('limit', String(args.limit));
98
+ q.set('limit', String(args.limit ?? (compact ? 10 : 50)));
99
+ if (compact)
100
+ q.set('compact', 'true');
101
+ if (args.since)
102
+ q.set('since', args.since);
88
103
  if (args.project)
89
104
  q.set('project', args.project);
90
105
  const instanceToken = mcpInstanceToken(undefined, extra);
91
106
  if (!args.includeOwnSends)
92
107
  q.set('excludeRef', instanceToken);
93
- const { messages, sessionId } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`, { instanceToken });
108
+ const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
109
+ const header = (m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
94
110
  const lines = messages.length === 0
95
111
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
96
- : messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
112
+ : messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
97
113
  if (messages.length > 0) {
98
114
  lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
99
115
  }
116
+ if (compact && counts)
117
+ lines.push(`${counts.unread} unread, ${counts.olderThan7Days} older than 7 days (all, limit N, since)`);
100
118
  if (args.dismiss) {
101
119
  lines.unshift(`Dismissed ${dismissedCount} message(s) as ${args.dismiss.reason}${closedCount > 0 ? `, ${closedCount} closed for the account` : ''}${feedbackSent > 0 ? `, ${feedbackSent} sender(s) told about the misroute` : ''}.`);
102
120
  }
@@ -110,7 +128,7 @@ export function makeAgentMessagesHandler(client) {
110
128
  sessionId: sessionId ?? null,
111
129
  messages: messages.map((m) => ({
112
130
  id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
113
- payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
131
+ payload: compact ? null : m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
114
132
  createdAt: m.createdAt, readAt: m.readAt, dismissed: m.dismissed ?? null,
115
133
  })),
116
134
  acked,
@@ -0,0 +1,38 @@
1
+ /** ORB-2508 - the Lexware connection 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 call = (method, path) => metricsToolName('orboto_api_call', { method, path });
5
+ describe('ORB-2508 - Lexware tail labels', () => {
6
+ it('labels status, readiness, logs and the profile test as orboto_lexware_connection', () => {
7
+ for (const path of ['/admin/integrations/lexware', '/admin/integrations/lexware/readiness', '/admin/integrations/lexware/events?limit=20', '/admin/integrations/lexware/deliveries']) {
8
+ expect(call('GET', path), path).toBe('orboto_lexware_connection');
9
+ }
10
+ expect(call('POST', '/admin/integrations/lexware/test')).toBe('orboto_lexware_connection');
11
+ });
12
+ it('labels the operator writes as orboto_lexware_admin', () => {
13
+ expect(call('POST', '/admin/integrations/lexware')).toBe('orboto_lexware_admin');
14
+ expect(call('POST', '/admin/integrations/lexware/rotate-key')).toBe('orboto_lexware_admin');
15
+ expect(call('POST', '/admin/integrations/lexware/subscriptions')).toBe('orboto_lexware_admin');
16
+ expect(call('PATCH', '/admin/integrations/lexware')).toBe('orboto_lexware_admin');
17
+ expect(call('DELETE', '/admin/integrations/lexware/subscriptions')).toBe('orboto_lexware_admin');
18
+ expect(call('DELETE', '/admin/integrations/lexware')).toBe('orboto_lexware_admin');
19
+ });
20
+ it('labels the customer contact mapping as orboto_lexware_contact (ORB-2511)', () => {
21
+ const path = '/customers/6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f/lexware-contact';
22
+ for (const method of ['GET', 'PUT', 'DELETE'])
23
+ expect(call(method, path), method).toBe('orboto_lexware_contact');
24
+ expect(call('GET', `${path}?name=Acme&page=1`)).toBe('orboto_lexware_contact');
25
+ });
26
+ it('labels the billing run preview, create, list and refresh as orboto_lexware_billing_run (ORB-2512)', () => {
27
+ const base = '/customers/6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f/lexware-billing-runs';
28
+ for (const path of [`${base}/preview`, base, `${base}/7a1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f/refresh`])
29
+ expect(call('POST', path), path).toBe('orboto_lexware_billing_run');
30
+ expect(call('GET', `${base}?limit=20`)).toBe('orboto_lexware_billing_run');
31
+ expect(call('GET', `${base}/summary?from=2026-10-01&to=2026-10-31`)).toBe('orboto_lexware_billing_run');
32
+ expect(call('GET', '/admin/integrations/lexware/billing-runs')).toBe('orboto_lexware_billing_run');
33
+ expect(call('DELETE', base)).toBe('orboto_api_call');
34
+ });
35
+ it('leaves the public receiver unlabelled', () => {
36
+ expect(call('POST', '/inbound/lexware/6f1c2b1e-3d4a-4b5c-8d9e-0a1b2c3d4e5f')).toBe('orboto_api_call');
37
+ });
38
+ });
@@ -54,23 +54,38 @@ export function makeUpdateProjectHandler(client) {
54
54
  };
55
55
  };
56
56
  }
57
+ /** ORB-2503 - a customer by UUID or by case-insensitive exact name; an ambiguous or unknown name is refused with the candidates. */
58
+ export async function resolveCustomerRef(client, ref) {
59
+ const wanted = ref.trim();
60
+ if (UUID_RE.test(wanted))
61
+ return wanted;
62
+ const rows = await client.get(`/customers?includeInactive=true&search=${encodeURIComponent(wanted)}`);
63
+ const exact = rows.filter((c) => c.name.trim().toLowerCase() === wanted.toLowerCase());
64
+ if (exact.length === 1)
65
+ return exact[0].id;
66
+ if (exact.length > 1)
67
+ throw new Error(`Customer name "${wanted}" is ambiguous: ${exact.map((c) => `${c.name} (${c.id})`).join(', ')}. Pass the id.`);
68
+ if (rows.length === 0)
69
+ throw new Error(`No customer named "${wanted}".`);
70
+ throw new Error(`No customer named exactly "${wanted}"; candidates: ${rows.map((c) => c.name).join(', ')}.`);
71
+ }
57
72
  export const createProjectToolConfig = {
58
- title: 'Create a new project',
59
- description: 'Create a project with an optional key (auto-derived if omitted). Requires project:create or super-admin. Returns identity, status and the measured readiness checklist with fix actions. Reuse its auto-created doc space via orboto_list_doc_spaces.',
73
+ title: 'Create a project',
74
+ description: 'Create a project (key auto-derived if omitted). Agents pass customer or internal. Needs project:create. Returns readiness with fix actions; its doc space is auto-created.',
60
75
  inputSchema: z.object({
61
- name: z.string().min(1).max(255).describe('Project name.'),
62
- key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional()
63
- .describe('Key; auto-derived if omitted.'),
64
- description: z.string().nullable().optional().describe('Description.'),
65
- customerId: z.string().regex(UUID_RE).nullable().optional()
66
- .describe('Customer UUID.'),
76
+ name: z.string().min(1).max(255),
77
+ key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional(),
78
+ description: z.string().nullable().optional(),
79
+ customerId: z.string().regex(UUID_RE).nullable().optional(),
80
+ customer: z.string().min(1).optional().describe('Customer name or UUID.'),
81
+ internal: z.string().min(3).max(200).optional().describe('Reason for no customer.'),
67
82
  language: z.enum(LOCALE_CODES).nullable().optional()
68
- .describe('Content language; null/omitted inherits workspace.'),
83
+ .describe('Null inherits workspace.'),
69
84
  }).shape,
70
85
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
71
86
  };
72
87
  export function makeCreateProjectHandler(client) {
73
- return async ({ name, key, description, customerId, language }) => {
88
+ return async ({ name, key, description, customerId, customer, internal, language }) => {
74
89
  const body = { name };
75
90
  if (key !== undefined)
76
91
  body.key = key;
@@ -78,6 +93,10 @@ export function makeCreateProjectHandler(client) {
78
93
  body.description = description;
79
94
  if (customerId !== undefined)
80
95
  body.customerId = customerId;
96
+ if (customer !== undefined)
97
+ body.customerId = await resolveCustomerRef(client, customer);
98
+ if (internal !== undefined)
99
+ Object.assign(body, { internal: true, internalReason: internal });
81
100
  if (language !== undefined)
82
101
  body.language = language;
83
102
  const created = await client.post('/projects', body);
@@ -96,6 +115,8 @@ export function makeCreateProjectHandler(client) {
96
115
  description: created.description,
97
116
  status: created.status,
98
117
  language: created.language ?? null,
118
+ customerId: created.customerId ?? null,
119
+ internal: created.internal ?? false,
99
120
  },
100
121
  };
101
122
  };
@@ -128,6 +128,32 @@ describe('orboto_create_project', () => {
128
128
  customerId: '11111111-2222-3333-4444-555555555555',
129
129
  });
130
130
  });
131
+ it('ORB-2503 - resolves customer by case-insensitive exact name before the POST', async () => {
132
+ const calls = stub([
133
+ { json: [{ id: 'c-2', name: 'Acme Labs' }, { id: 'c-1', name: 'ACME' }] },
134
+ { json: { readiness, id: 'new4', key: 'AC', name: 'Acme app', description: null, status: 'active', customerId: 'c-1', internal: false } },
135
+ ]);
136
+ const res = await makeCreateProjectHandler(client)({ name: 'Acme app', customer: 'acme' });
137
+ expect(calls[0].url).toBe('https://orboto.example.com/customers?includeInactive=true&search=acme');
138
+ expect(calls[1].body).toEqual({ name: 'Acme app', customerId: 'c-1' });
139
+ expect(res.structuredContent).toMatchObject({ customerId: 'c-1', internal: false });
140
+ });
141
+ it('ORB-2503 - refuses an ambiguous customer name with the candidates and never POSTs', async () => {
142
+ const calls = stub([{ json: [{ id: 'c-1', name: 'Acme' }, { id: 'c-2', name: 'acme' }] }]);
143
+ await expect(makeCreateProjectHandler(client)({ name: 'X', customer: 'ACME' }))
144
+ .rejects.toThrow(/ambiguous: Acme \(c-1\), acme \(c-2\)/);
145
+ expect(calls).toHaveLength(1);
146
+ });
147
+ it('ORB-2503 - refuses an unknown customer name and lists near matches', async () => {
148
+ stub([{ json: [{ id: 'c-1', name: 'Acme Labs' }] }]);
149
+ await expect(makeCreateProjectHandler(client)({ name: 'X', customer: 'Acme' }))
150
+ .rejects.toThrow(/candidates: Acme Labs/);
151
+ });
152
+ it('ORB-2503 - sends internal with its reason', async () => {
153
+ const calls = stub([{ json: { readiness, id: 'new5', key: 'OPS', name: 'Ops', description: null, status: 'active', customerId: null, internal: true } }]);
154
+ await makeCreateProjectHandler(client)({ name: 'Ops', internal: 'Our own infrastructure' });
155
+ expect(calls[0].body).toEqual({ name: 'Ops', internal: true, internalReason: 'Our own infrastructure' });
156
+ });
131
157
  it('surfaces a 409 (duplicate key) as OrbotoApiError', async () => {
132
158
  stub([
133
159
  { ok: false, status: 409, json: { error: 'project key already exists' } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.220.0",
3
+ "version": "0.221.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",