@orboto/mcp 0.178.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.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ORB-1089 - MCP tools to manage the configurable coding-agent
3
+ * working-rule blocks. Mirrors the REST CRUD (admin:ai:read/write
4
+ * gated server-side; a 403 means the caller's key lacks the slug).
5
+ * Part of the 4-way sync (REST + MCP + skill + in-app chat).
6
+ */
7
+ import { z } from 'zod';
8
+ import { sizeBlockResult } from './shared.js';
9
+ // ORB-1819 - the writing contract, verbatim in every write-tool
10
+ // description so the writing agent sees it at the moment of writing.
11
+ const RULE_WRITING_CONTRACT = ' Rule: title = one imperative sentence; body <= 400 chars, Rule / Why / How, no long examples, nothing another block says.';
12
+ // ORB-1700 - a LIST answers "what exists"; the body belongs to the
13
+ // follow-up read (blockId input below). First line, hard-capped.
14
+ function excerptOf(body) {
15
+ const firstLine = body.split('\n', 1)[0] ?? '';
16
+ return firstLine.length > 200 ? `${firstLine.slice(0, 199)}\u2026` : firstLine;
17
+ }
18
+ function renderBlock(b) {
19
+ const flag = b.enabled ? '' : ' [disabled]';
20
+ const kind = b.builtinKey ? `default:${b.builtinKey}` : 'custom';
21
+ return `- ${b.title}${flag} (${kind}, ${b.body.length} chars, id ${b.id})\n ${excerptOf(b.body)}`;
22
+ }
23
+ export const listAgentInstructionsToolConfig = {
24
+ title: 'MANAGE the workspace coding-agent rule blocks (admin)',
25
+ description: 'ADMIN/MANAGEMENT tool - lists the individual rule BLOCKS at a scope (workspace: needs admin:ai:read; project: needs project:edit; personal: your own) so they can be edited/toggled/reordered. This is NOT how you read the rules to follow. To LOAD the rules you must follow as an agent, call orboto_session_start instead - it returns the complete assembled rule set.',
26
+ inputSchema: z.object({
27
+ scope: z.enum(['workspace', 'customer', 'project', 'personal']).default('workspace'),
28
+ projectId: z.string().uuid().optional().describe('Required for scope=project.'),
29
+ customerId: z.string().uuid().optional().describe('Required for scope=customer.'),
30
+ blockId: z.string().uuid().optional().describe('Return THIS block with its full body (ORB-1700). Without it the list carries excerpts + contentChars only.'),
31
+ }).shape,
32
+ annotations: { readOnlyHint: true, idempotentHint: true },
33
+ };
34
+ function scopeQs(scope, projectId, customerId) {
35
+ const p = new URLSearchParams({ scope });
36
+ if (projectId)
37
+ p.set('projectId', projectId);
38
+ if (customerId)
39
+ p.set('customerId', customerId);
40
+ return p.toString();
41
+ }
42
+ export function makeListAgentInstructionsHandler(client) {
43
+ return async (input = {}) => {
44
+ const res = await client.get(`/agent-instructions/blocks?${scopeQs(input.scope ?? 'workspace', input.projectId, input.customerId)}`);
45
+ // ORB-1700 - full body for ONE explicitly named block, in one call.
46
+ if (input.blockId) {
47
+ const block = res.blocks.find((b) => b.id === input.blockId);
48
+ if (!block) {
49
+ return {
50
+ content: [{ type: 'text', text: `No rule block with id ${input.blockId} at this scope.` }],
51
+ structuredContent: { block: null },
52
+ isError: true,
53
+ };
54
+ }
55
+ return {
56
+ content: [{ type: 'text', text: `# ${block.title}${block.enabled ? '' : ' [disabled]'}\n\n${block.body}` }],
57
+ structuredContent: { block },
58
+ };
59
+ }
60
+ // ORB-1700 - list = metadata + excerpt. The assembled 24k rule text is
61
+ // NOT re-shipped here (it rode along on every management call and cost
62
+ // 238 Mtok over 3 calls); agents load the rules via orboto_session_start.
63
+ const text = res.blocks.length
64
+ ? res.blocks.map(renderBlock).join('\n')
65
+ : 'No rule blocks configured.';
66
+ return {
67
+ content: [{ type: 'text', text }],
68
+ structuredContent: {
69
+ // Array order = sortOrder; builtinKey only when it is one; the
70
+ // descriptive TITLE is the excerpt (bodies via blockId).
71
+ blocks: res.blocks.map((b) => ({
72
+ id: b.id,
73
+ title: b.title,
74
+ enabled: b.enabled,
75
+ contentChars: b.body.length,
76
+ ...(b.builtinKey ? { builtinKey: b.builtinKey } : {}),
77
+ })),
78
+ bodyHint: 'Full body of one block: re-call with blockId. The assembled agent-facing rule set comes from orboto_session_start.',
79
+ },
80
+ };
81
+ };
82
+ }
83
+ export const createAgentInstructionToolConfig = {
84
+ title: 'Add a custom coding-agent rule block',
85
+ description: 'Create a custom rule block at a scope: workspace (every agent; admin:ai:write), customer (every project of one customer; customer:write), project (one project; project:edit), or personal (your own). For scope=customer pass customerId; for scope=project pass projectId.'
86
+ + RULE_WRITING_CONTRACT,
87
+ inputSchema: z.object({
88
+ title: z.string().min(1).max(120),
89
+ body: z.string().min(1).max(8000).describe('The rule text the agent should follow.'),
90
+ scope: z.enum(['workspace', 'customer', 'project', 'personal']).default('workspace'),
91
+ projectId: z.string().uuid().optional().describe('Required for scope=project.'),
92
+ customerId: z.string().uuid().optional().describe('Required for scope=customer.'),
93
+ enabled: z.boolean().optional(),
94
+ sortOrder: z.number().int().optional(),
95
+ allowOversize: z.boolean().optional().describe('Override the hard size-cap block (400*2 chars). Only after a call was blocked.'),
96
+ oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this body genuinely needs the length. Audit-logged.'),
97
+ }).shape,
98
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
99
+ };
100
+ export function makeCreateAgentInstructionHandler(client) {
101
+ return async (input) => {
102
+ const { scope, projectId, customerId, ...body } = input;
103
+ let row;
104
+ try {
105
+ row = await client.post(`/agent-instructions/blocks?${scopeQs(scope ?? 'workspace', projectId, customerId)}`, body);
106
+ }
107
+ catch (err) {
108
+ const blocked = sizeBlockResult(err, 'Rule block create');
109
+ if (blocked)
110
+ return blocked;
111
+ throw err;
112
+ }
113
+ const warn = row.sizeWarning ? `\n⚠ ${row.body.length} chars, over the ${row.sizeWarning.limit}-char soft limit. ${row.sizeWarning.hint}` : '';
114
+ return { content: [{ type: 'text', text: `Created rule block "${row.title}" (id ${row.id}).${warn}` }], structuredContent: row };
115
+ };
116
+ }
117
+ export const updateAgentInstructionToolConfig = {
118
+ title: 'Edit / toggle / reorder a coding-agent rule block',
119
+ description: 'Patch a rule block: title, body, enabled (toggle a rule on/off), or sortOrder. Works on default AND custom blocks (defaults keep their builtinKey and can be reset later). Needs admin:ai:write.'
120
+ + RULE_WRITING_CONTRACT,
121
+ inputSchema: z.object({
122
+ id: z.string().uuid(),
123
+ title: z.string().min(1).max(120).optional(),
124
+ body: z.string().min(1).max(8000).optional(),
125
+ enabled: z.boolean().optional(),
126
+ sortOrder: z.number().int().optional(),
127
+ allowOversize: z.boolean().optional().describe('Override the hard size-cap block (400*2 chars). Only after a call was blocked.'),
128
+ oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this body genuinely needs the length. Audit-logged.'),
129
+ }).shape,
130
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
131
+ };
132
+ export function makeUpdateAgentInstructionHandler(client) {
133
+ return async (input) => {
134
+ const { id, ...patch } = input;
135
+ let row;
136
+ try {
137
+ row = await client.patch(`/agent-instructions/blocks/${id}`, patch);
138
+ }
139
+ catch (err) {
140
+ const blocked = sizeBlockResult(err, 'Rule block update');
141
+ if (blocked)
142
+ return blocked;
143
+ throw err;
144
+ }
145
+ const warn = row.sizeWarning ? `\n⚠ ${row.body.length} chars, over the ${row.sizeWarning.limit}-char soft limit. ${row.sizeWarning.hint}` : '';
146
+ return { content: [{ type: 'text', text: `Updated rule block "${row.title}" (enabled: ${row.enabled}).${warn}` }], structuredContent: row };
147
+ };
148
+ }
149
+ export const resetAgentInstructionToolConfig = {
150
+ title: 'Reset a default rule block to its shipped text',
151
+ description: 'Restore a seeded DEFAULT rule block (one with a builtinKey) to the text orboto ships. No-op for custom blocks. Needs admin:ai:write.',
152
+ inputSchema: z.object({ id: z.string().uuid() }).shape,
153
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
154
+ };
155
+ export function makeResetAgentInstructionHandler(client) {
156
+ return async (input) => {
157
+ const row = await client.post(`/agent-instructions/blocks/${input.id}/reset`, {});
158
+ return { content: [{ type: 'text', text: `Reset rule block "${row.title}" to its default text.` }], structuredContent: row };
159
+ };
160
+ }
161
+ export const deleteAgentInstructionToolConfig = {
162
+ title: 'Delete a custom coding-agent rule block',
163
+ description: 'Delete a CUSTOM rule block. Default (builtin) blocks cannot be deleted - disable them via update instead. Needs admin:ai:write.',
164
+ inputSchema: z.object({ id: z.string().uuid() }).shape,
165
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
166
+ };
167
+ export function makeDeleteAgentInstructionHandler(client) {
168
+ return async (input) => {
169
+ await client.delete(`/agent-instructions/blocks/${input.id}`);
170
+ return { content: [{ type: 'text', text: `Deleted rule block ${input.id}.` }], structuredContent: { id: input.id, deleted: true } };
171
+ };
172
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ORB-1089 - agent-instruction management MCP tools. Assert the
3
+ * outgoing wire shape (method + URL + body) against the admin REST
4
+ * surface, catching shape drift at the unit layer.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { OrbotoClient } from '../orboto-client.js';
8
+ import { makeListAgentInstructionsHandler, makeCreateAgentInstructionHandler, makeUpdateAgentInstructionHandler, makeResetAgentInstructionHandler, makeDeleteAgentInstructionHandler, } from './agent-instructions.js';
9
+ beforeEach(() => { vi.restoreAllMocks(); });
10
+ afterEach(() => { vi.restoreAllMocks(); });
11
+ function stubJSON(responses) {
12
+ const calls = [];
13
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
14
+ calls.push({
15
+ url: url.toString(),
16
+ method: init?.method ?? 'GET',
17
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
18
+ });
19
+ const r = responses.shift();
20
+ if (!r)
21
+ throw new Error('unexpected extra fetch');
22
+ return { ok: r.ok ?? true, status: r.status ?? 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
23
+ });
24
+ return calls;
25
+ }
26
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
27
+ const BLOCK = { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', builtinKey: null, title: 'Rule', body: 'do the thing', enabled: true, sortOrder: 10 };
28
+ describe('agent-instruction MCP tools (ORB-1089)', () => {
29
+ it('ORB-1700: list returns metadata + excerpt, never full bodies or the assembled text', async () => {
30
+ const calls = stubJSON([{ json: { blocks: [BLOCK], assembled: 'do the thing' } }]);
31
+ const res = await makeListAgentInstructionsHandler(client)();
32
+ expect(calls[0].method).toBe('GET');
33
+ expect(calls[0].url).toContain('/agent-instructions/blocks?scope=workspace');
34
+ const sc = res.structuredContent;
35
+ expect(sc.assembled).toBeUndefined();
36
+ expect(sc.blocks[0].body).toBeUndefined();
37
+ expect(sc.blocks[0].contentChars).toBe(BLOCK.body.length);
38
+ expect(Object.keys(sc.blocks[0]).sort()).toEqual(['contentChars', 'enabled', 'id', 'title']);
39
+ });
40
+ it('ORB-1700: blockId returns the one block with its full body in one call', async () => {
41
+ stubJSON([{ json: { blocks: [BLOCK], assembled: 'x' } }]);
42
+ const res = await makeListAgentInstructionsHandler(client)({ blockId: BLOCK.id });
43
+ const sc = res.structuredContent;
44
+ expect(sc.block.body).toBe(BLOCK.body);
45
+ });
46
+ it('create at project scope carries scope + projectId', async () => {
47
+ const calls = stubJSON([{ status: 201, json: BLOCK }]);
48
+ await makeCreateAgentInstructionHandler(client)({ title: 'P', body: 'b', scope: 'project', projectId: '22222222-2222-2222-2222-222222222222' });
49
+ expect(calls[0].url).toContain('scope=project');
50
+ expect(calls[0].url).toContain('projectId=22222222-2222-2222-2222-222222222222');
51
+ });
52
+ it('create POSTs title + body', async () => {
53
+ const calls = stubJSON([{ status: 201, json: BLOCK }]);
54
+ await makeCreateAgentInstructionHandler(client)({ title: 'Rule', body: 'do the thing' });
55
+ expect(calls[0].method).toBe('POST');
56
+ expect(calls[0].url).toContain('/agent-instructions/blocks?scope=workspace');
57
+ expect(calls[0].body).toEqual({ title: 'Rule', body: 'do the thing' });
58
+ });
59
+ it('update PATCHes by id without the id in the body', async () => {
60
+ const calls = stubJSON([{ json: { ...BLOCK, enabled: false } }]);
61
+ await makeUpdateAgentInstructionHandler(client)({ id: BLOCK.id, enabled: false });
62
+ expect(calls[0]).toMatchObject({ method: 'PATCH', url: `https://orboto.example.com/agent-instructions/blocks/${BLOCK.id}`, body: { enabled: false } });
63
+ expect(calls[0].body).not.toHaveProperty('id');
64
+ });
65
+ it('reset POSTs to the reset sub-path', async () => {
66
+ const calls = stubJSON([{ json: BLOCK }]);
67
+ await makeResetAgentInstructionHandler(client)({ id: BLOCK.id });
68
+ expect(calls[0]).toMatchObject({ method: 'POST', url: `https://orboto.example.com/agent-instructions/blocks/${BLOCK.id}/reset` });
69
+ });
70
+ it('delete DELETEs by id', async () => {
71
+ const calls = stubJSON([{ json: {} }]);
72
+ const res = await makeDeleteAgentInstructionHandler(client)({ id: BLOCK.id });
73
+ expect(calls[0]).toMatchObject({ method: 'DELETE', url: `https://orboto.example.com/agent-instructions/blocks/${BLOCK.id}` });
74
+ expect(res.structuredContent).toMatchObject({ deleted: true });
75
+ });
76
+ // ORB-1819 - the writing-for-tokens size contract, passed through from
77
+ // the REST route's warn (200 + sizeWarning) / block (422) responses.
78
+ describe('ORB-1819 size contract', () => {
79
+ it('create surfaces a soft-limit sizeWarning in the success text, not as an error', async () => {
80
+ const oversizeBlock = { ...BLOCK, body: 'x'.repeat(450), sizeWarning: { chars: 450, limit: 400, hint: 'move it to a doc' } };
81
+ stubJSON([{ status: 201, json: oversizeBlock }]);
82
+ const res = await makeCreateAgentInstructionHandler(client)({ title: 'Long', body: 'x'.repeat(450) });
83
+ expect(res.isError).toBeUndefined();
84
+ const text = res.content[0].text;
85
+ expect(text).toContain('450 chars');
86
+ expect(text).toContain('move it to a doc');
87
+ });
88
+ it('create turns a hard-cap 422 into a non-throwing blocked result carrying the override recipe', async () => {
89
+ const blockBody = JSON.stringify({
90
+ error: 'This rule content is 850 characters, over the 800-character hard limit.',
91
+ errorKey: 'errors.agent_content.oversize',
92
+ sizeWarning: { chars: 850, limit: 800, hint: 'move it to a doc and keep the doc key here' },
93
+ });
94
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody }));
95
+ const res = await makeCreateAgentInstructionHandler(client)({ title: 'Way too long', body: 'x'.repeat(850) });
96
+ expect(res.isError).toBe(true);
97
+ const text = res.content[0].text;
98
+ expect(text).toContain('850');
99
+ expect(text).toContain('allowOversize=true');
100
+ expect(text).toContain('oversizeReason');
101
+ const sc = res.structuredContent;
102
+ expect(sc.blocked).toBe(true);
103
+ expect(sc.sizeWarning.limit).toBe(800);
104
+ });
105
+ it('create passes allowOversize + oversizeReason through to the POST body', async () => {
106
+ const calls = stubJSON([{ status: 201, json: { ...BLOCK, sizeWarning: { chars: 850, limit: 800, hint: 'h' } } }]);
107
+ await makeCreateAgentInstructionHandler(client)({
108
+ title: 'Long', body: 'x'.repeat(850), allowOversize: true, oversizeReason: 'genuinely needs the full list',
109
+ });
110
+ expect(calls[0].body).toMatchObject({ allowOversize: true, oversizeReason: 'genuinely needs the full list' });
111
+ });
112
+ it('update turns a hard-cap 422 into a non-throwing blocked result', async () => {
113
+ const blockBody = JSON.stringify({
114
+ error: 'oversize', errorKey: 'errors.agent_content.oversize',
115
+ sizeWarning: { chars: 900, limit: 800, hint: 'shorten it' },
116
+ });
117
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody }));
118
+ const res = await makeUpdateAgentInstructionHandler(client)({ id: BLOCK.id, body: 'x'.repeat(900) });
119
+ expect(res.isError).toBe(true);
120
+ const sc = res.structuredContent;
121
+ expect(sc.blocked).toBe(true);
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ORB-1727 - `orboto_messages`: fetch + ack the caller's agent inbox.
3
+ *
4
+ * The store-and-forward half of agent messaging (epic ORB-1726): send via
5
+ * `orboto_agent_notify` (which now persists a durable inbox row), receive
6
+ * HERE. While mail is pending, every tool response carries a one-line
7
+ * pointer to this tool (appended centrally in with-metrics.ts from the
8
+ * api's `x-orboto-agent-mail` response header - no polling anywhere).
9
+ */
10
+ import { z } from 'zod';
11
+ import { mcpInstanceToken } from './shared.js';
12
+ export const agentMessagesToolConfig = {
13
+ title: 'Fetch / acknowledge your agent inbox',
14
+ description: 'Read the messages other agents sent you while you were not connected (store-and-forward inbox; sending happens via orboto_agent_notify). Default lists UNREAD messages and marks them delivered; pass ackIds to acknowledge messages as read (acked messages stop triggering the pending-mail pointer on tool responses and prune after 30 days). Reply by calling orboto_agent_notify with threadId = the message id you are answering.',
15
+ inputSchema: z.object({
16
+ all: z.boolean().default(false).describe('true = include already-read messages'),
17
+ limit: z.number().int().min(1).max(200).default(50),
18
+ project: z.string().min(1).max(64).optional().describe('Project key or UUID: narrow to messages scoped to this project PLUS unscoped ones. Pass the project you are working when your identity runs multiple sessions; only ack messages that are yours.'),
19
+ // ORB-1742 - self-echo exclusion, on by default for MCP sessions.
20
+ includeOwnSends: z.boolean().default(false).describe('true = ALSO list messages this very session sent (they are hidden by default so a shared identity never wakes itself with its own outbound mail).'),
21
+ ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark as read'),
22
+ }).shape,
23
+ outputSchema: z.object({
24
+ messages: z.array(z.object({
25
+ id: z.string(),
26
+ fromUserId: z.string(),
27
+ kind: z.string(),
28
+ subject: z.string(),
29
+ payload: z.record(z.string(), z.unknown()).nullable(),
30
+ threadId: z.string().nullable(),
31
+ projectKey: z.string().nullable(),
32
+ createdAt: z.string(),
33
+ readAt: z.string().nullable(),
34
+ })),
35
+ acked: z.number().int(),
36
+ }).shape,
37
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
38
+ };
39
+ export function makeAgentMessagesHandler(client) {
40
+ return async (args, extra) => {
41
+ let acked = 0;
42
+ if (args.ackIds && args.ackIds.length > 0) {
43
+ const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds });
44
+ acked = res.acked;
45
+ }
46
+ const q = new URLSearchParams();
47
+ if (args.all)
48
+ q.set('all', 'true');
49
+ if (args.limit)
50
+ q.set('limit', String(args.limit));
51
+ if (args.project)
52
+ q.set('project', args.project);
53
+ if (!args.includeOwnSends) {
54
+ q.set('excludeRef', mcpInstanceToken(undefined, extra));
55
+ }
56
+ const { messages } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`);
57
+ const lines = messages.length === 0
58
+ ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
59
+ : messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
60
+ if (messages.length > 0) {
61
+ lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId.`);
62
+ }
63
+ return {
64
+ content: [{ type: 'text', text: lines.join('\n') }],
65
+ structuredContent: {
66
+ messages: messages.map((m) => ({
67
+ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject,
68
+ payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
69
+ createdAt: m.createdAt, readAt: m.readAt,
70
+ })),
71
+ acked,
72
+ },
73
+ };
74
+ };
75
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * ORB-564 - `orboto_ai_status`.
3
+ *
4
+ * Pre-flight check for agents: is the workspace's AI provider configured?
5
+ * Wraps `GET /ai/status`. Two flags come back - chat features and
6
+ * embeddings - because Anthropic-only deployments have chat fully
7
+ * wired but cannot produce embeddings, and RAG-style features
8
+ * (`ask-docs`, similar-tickets rerank, partial-overlap detection)
9
+ * need both.
10
+ *
11
+ * Today no MCP tool requires AI directly - every `orboto_*` tool is a
12
+ * thin REST wrapper that does its own thing. The dependency lives on
13
+ * the skill side (`orboto ask-docs`) and on chat-only LLM calls the
14
+ * agent host might make. This tool exists so an agent can plan around
15
+ * the workspace shape before calling those skill shortcuts or before
16
+ * suggesting AI-gated features to the operator.
17
+ */
18
+ import { z } from 'zod';
19
+ export const aiStatusToolConfig = {
20
+ title: 'Check whether the orboto workspace has AI configured',
21
+ description: 'Pre-flight check for AI-gated operations. Returns two flags: `configured` (chat / completion provider set up - required by `ask-docs`, summarisation, ticket polish, suggest-title, suggest-priority, suggest-labels, translate, NL search, retro generation, daily digest, milestone risk, ticket split) and `embeddingsConfigured` (embedding provider set up - required by RAG features like `ask-docs` and similar-tickets rerank), plus `visionEnabled` (image attachments allowed in AI calls - the `ai_vision_enabled` workspace toggle). Anthropic-only deployments return `embeddingsConfigured: false` because Anthropic does not produce embeddings. Call this before invoking AI-gated skill shortcuts so you can plan around a workspace that has AI disabled.',
22
+ inputSchema: z.object({}).shape,
23
+ outputSchema: z.object({
24
+ configured: z.boolean(),
25
+ embeddingsConfigured: z.boolean(),
26
+ visionEnabled: z.boolean(),
27
+ }).shape,
28
+ annotations: { readOnlyHint: true, idempotentHint: true },
29
+ };
30
+ export function makeAiStatusHandler(client) {
31
+ return async () => {
32
+ const status = await client.get('/ai/status');
33
+ const visionEnabled = status.visionEnabled ?? false;
34
+ const lines = [];
35
+ lines.push(`Chat / completion AI: ${status.configured ? 'configured' : 'NOT configured'}`);
36
+ lines.push(`Embeddings: ${status.embeddingsConfigured ? 'configured' : 'NOT configured'}`);
37
+ lines.push(`Vision (image attachments): ${visionEnabled ? 'enabled' : 'disabled'}`);
38
+ if (!status.configured) {
39
+ lines.push('');
40
+ lines.push('AI-gated operations (skill `ask-docs`, summarisation, suggest-*, etc.) will fail with a 400 until the workspace operator configures an AI provider in Admin → AI Settings.');
41
+ }
42
+ else if (!status.embeddingsConfigured) {
43
+ lines.push('');
44
+ lines.push('RAG features (`ask-docs`, similar-tickets rerank, partial-overlap detection) need an embedding-capable provider - Anthropic-only setups do not have one.');
45
+ }
46
+ return {
47
+ content: [{ type: 'text', text: lines.join('\n') }],
48
+ structuredContent: {
49
+ configured: status.configured,
50
+ embeddingsConfigured: status.embeddingsConfigured,
51
+ visionEnabled,
52
+ },
53
+ };
54
+ };
55
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * ORB-564 - `orboto_ai_status` tool mapping test.
3
+ */
4
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
5
+ import { OrbotoClient } from '../orboto-client.js';
6
+ import { makeAiStatusHandler } from './ai-status.js';
7
+ beforeEach(() => { vi.restoreAllMocks(); });
8
+ afterEach(() => { vi.restoreAllMocks(); });
9
+ function mockFetch(json) {
10
+ return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
11
+ ok: true, status: 200, statusText: 'OK',
12
+ json: async () => json,
13
+ });
14
+ }
15
+ describe('tools/ai-status', () => {
16
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
17
+ it('reports all flags when fully configured + vision on (ORB-1264)', async () => {
18
+ mockFetch({ configured: true, embeddingsConfigured: true, visionEnabled: true });
19
+ const result = await makeAiStatusHandler(client)();
20
+ expect(result.structuredContent).toEqual({ configured: true, embeddingsConfigured: true, visionEnabled: true });
21
+ expect(result.content[0].text).toContain('Chat / completion AI: configured');
22
+ expect(result.content[0].text).toContain('Embeddings: configured');
23
+ expect(result.content[0].text).toContain('Vision (image attachments): enabled');
24
+ });
25
+ it('warns about RAG features when only chat is configured', async () => {
26
+ mockFetch({ configured: true, embeddingsConfigured: false, visionEnabled: false });
27
+ const result = await makeAiStatusHandler(client)();
28
+ expect(result.structuredContent).toEqual({ configured: true, embeddingsConfigured: false, visionEnabled: false });
29
+ const text = result.content[0].text;
30
+ expect(text).toContain('Chat / completion AI: configured');
31
+ expect(text).toContain('Embeddings: NOT configured');
32
+ expect(text).toContain('Vision (image attachments): disabled');
33
+ expect(text).toContain('RAG features');
34
+ });
35
+ it('treats an older server omitting visionEnabled as disabled', async () => {
36
+ mockFetch({ configured: false, embeddingsConfigured: false });
37
+ const result = await makeAiStatusHandler(client)();
38
+ expect(result.structuredContent).toEqual({ configured: false, embeddingsConfigured: false, visionEnabled: false });
39
+ const text = result.content[0].text;
40
+ expect(text).toContain('Chat / completion AI: NOT configured');
41
+ expect(text).toContain('AI-gated operations');
42
+ });
43
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ORB-1321 - `orboto_ai_usage`.
3
+ *
4
+ * Operator diagnostic for AI *consumption*: total calls / tokens in+out /
5
+ * errors over a date range, plus per-user, per-operation, and per-day
6
+ * breakdowns and the AI-Chat slice. Wraps GET /admin/ai/usage.
7
+ *
8
+ * Complements the other two AI diagnostics: `orboto_ai_status` says whether AI
9
+ * is configured; `orboto_embedding_status` covers the embedding pipeline's
10
+ * health; this one answers "how much are we spending / how many calls are
11
+ * erroring". Aggregates only (no per-row error text). admin:ai:read gated
12
+ * (403 for non-admin callers).
13
+ */
14
+ import { z } from 'zod';
15
+ export const aiUsageToolConfig = {
16
+ title: 'AI usage (calls / tokens / errors)',
17
+ description: 'Operator diagnostic for AI consumption over a date range (defaults to the last 30 days). Returns totals (calls, tokens in / out, error count) plus per-user, per-operation and per-day breakdowns and the AI-Chat slice (conversation count + top chat users). Use this to see spend / call volume / which operations run most / how many calls are erroring. Aggregates only - not the per-row error messages. Pair with orboto_embedding_status (pipeline health) and orboto_ai_status (is AI configured). Requires admin:ai:read - 403 for non-admin callers.',
18
+ inputSchema: z.object({
19
+ start: z.string().optional().describe('Range start, YYYY-MM-DD (inclusive). Defaults to 30 days ago.'),
20
+ end: z.string().optional().describe('Range end, YYYY-MM-DD (inclusive). Defaults to today.'),
21
+ }).shape,
22
+ outputSchema: z.object({
23
+ calls: z.number(),
24
+ tokensIn: z.number(),
25
+ tokensOut: z.number(),
26
+ errors: z.number(),
27
+ chatConversations: z.number(),
28
+ topOperations: z.array(z.object({ operation: z.string(), calls: z.number() })),
29
+ topUsers: z.array(z.object({ userName: z.string().nullable(), calls: z.number(), tokensIn: z.number(), tokensOut: z.number() })),
30
+ }).shape,
31
+ annotations: { readOnlyHint: true, idempotentHint: true },
32
+ };
33
+ export function makeAiUsageHandler(client) {
34
+ return async (args) => {
35
+ const qs = new URLSearchParams();
36
+ if (args.start)
37
+ qs.set('start', args.start);
38
+ if (args.end)
39
+ qs.set('end', args.end);
40
+ const suffix = qs.toString() ? `?${qs.toString()}` : '';
41
+ const u = await client.get(`/admin/ai/usage${suffix}`);
42
+ const topOps = u.perOperation.slice(0, 8);
43
+ const topUsers = u.perUser.slice(0, 8);
44
+ const lines = [];
45
+ lines.push(`AI usage${args.start || args.end ? ` (${args.start ?? 'start'} → ${args.end ?? 'today'})` : ' (last 30 days)'}`);
46
+ lines.push(`Totals: ${u.totals.calls} calls · ${u.totals.tokensIn} tokens in · ${u.totals.tokensOut} out · ${u.totals.errors} errors`);
47
+ if (u.totals.errors > 0) {
48
+ lines.push(` ⚠ ${u.totals.errors} call(s) errored - check Admin → AI → Usage (or the ai_usage_log) for the messages; aggregates don't carry them.`);
49
+ }
50
+ if (topOps.length)
51
+ lines.push(`Top operations: ${topOps.map((o) => `${o.operation} (${o.calls})`).join(', ')}`);
52
+ if (topUsers.length)
53
+ lines.push(`Top users: ${topUsers.map((x) => `${x.userName ?? 'unknown'} (${x.calls})`).join(', ')}`);
54
+ lines.push(`AI Chat: ${u.chatConversations} conversation(s)`);
55
+ return {
56
+ content: [{ type: 'text', text: lines.join('\n') }],
57
+ structuredContent: {
58
+ calls: u.totals.calls,
59
+ tokensIn: u.totals.tokensIn,
60
+ tokensOut: u.totals.tokensOut,
61
+ errors: u.totals.errors,
62
+ chatConversations: u.chatConversations,
63
+ topOperations: topOps,
64
+ topUsers: topUsers.map((x) => ({ userName: x.userName, calls: x.calls, tokensIn: x.tokensIn, tokensOut: x.tokensOut })),
65
+ },
66
+ };
67
+ };
68
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * ORB-1032 - `orboto_analytics`. One multiplexed read tool over the project
3
+ * analytics suite + Earned Value, since AI is a main feature. Inherits the
4
+ * ORB-1031 permission gate: the API returns 403 for reports the caller
5
+ * can't see (budget / earned-value money mode need budget:view).
6
+ */
7
+ import { z } from 'zod';
8
+ import { OrbotoApiError } from '../orboto-client.js';
9
+ import { resolveProjectByKey } from './shared.js';
10
+ import { resolveMilestoneByNameOrId } from './milestones.js';
11
+ const REPORTS = ['overview', 'burndown', 'velocity', 'cycle-time', 'workload', 'budget', 'collaboration', 'earned-value', 'estimation-accuracy', 'flow-time', 'flow-metrics', 'forecast', 'bottleneck'];
12
+ export const analyticsToolConfig = {
13
+ title: 'Project analytics',
14
+ description: "Read a project's analytics or Earned Value. `report`: overview, burndown, velocity, cycle-time, workload, budget, collaboration, earned-value, estimation-accuracy, flow-time, flow-metrics, forecast, or bottleneck. `collaboration` classifies tickets human-only / agent-only / mixed from the agent-work stamps (time entries, comments, activities), with agent share of effort per project / milestone / member and a weekly trend - use it for 'who works with agents and what comes out of it' questions; `milestone` scopes it. `bottleneck` shows which status clogs the flow (longest-dwell status + trend), delivery predictability (lead/cycle variance), and the worst-aging open tickets with assignee. `forecast` is a Monte-Carlo delivery forecast (probabilistic 'done by X' date with p50/p85/p95 bands from historical throughput) - reach for it on 'when will this be done / how long for N tickets' questions instead of guessing from cycle time; `milestone` scopes its remaining set. `flow-metrics` gives Kanban flow: current WIP (leaf tickets only - `epicWip`/`epicWipByCategory` report epics separately, `excludedEpicsCount` says how many), weekly throughput, flow efficiency (active vs queue time), aging WIP, and a cumulative flow diagram. `flow-time` shows lead vs cycle vs effort side-by-side as median + p75/p90 (NOT just mean - the legacy cycle-time report's mean hid a 0-day median); sub-day medians round to 0 in the Days fields, so also check the parallel `leadMinutes`/`cycleMinutes` fields for fast-moving cohorts. `estimation-accuracy` is the estimate-vs-actual calibration (multiplier + confidence; degrades through tracked-effort -> cycle-time -> lead-time and reports insufficient rather than inventing a number) - reach for estimation-accuracy + flow-time + forecast for grounded effort/duration answers instead of free-reasoning from cycle time. For `bottleneck`/`flow-time`/`flow-metrics`/`estimation-accuracy`, prefer the `byWorkOrigin`/`cohortsByWorkOrigin`/`predictabilityByWorkOrigin` fields (agent/human/mixed/unknown, derived from WHO actually did the work) over the legacy `byCohort`/`cohorts`/`predictability` fields (agents/humans/combined, derived from account type only) - an agent operating through a human account reads correctly under workOrigin but not under accountType, so the two can disagree on the same ticket set. `unknown` is a real bucket, not a rounding error: it counts tickets with NO agent-work stamp anywhere, and you must read the other three against it - a big `unknown` means the split covers only a slice of the sample. Note also that `is_agent_work` only exists from its migration onward with no backfill, so on a workspace with history predating it the `human` bucket carries pre-stamp rows that nobody actually measured; cross-check a surprising human-heavy split against the `collaboration` weekly trend before drawing a conclusion from it. `milestone` scopes burndown + earned-value + forecast; `mode` (hours|money) applies to earned-value. Requires `analytics:view`; `budget` and the earned-value MONEY mode additionally require `budget:view` (you'll get a permission error otherwise).",
15
+ inputSchema: z.object({
16
+ projectKey: z.string().min(1).describe('Project key (e.g. "ORB").'),
17
+ report: z.enum(REPORTS).describe('Which report to return.'),
18
+ milestone: z.string().optional().describe('Milestone name (burndown / earned-value / forecast / collaboration).'),
19
+ mode: z.enum(['hours', 'money']).optional().describe('earned-value unit (default hours). Money needs budget:view.'),
20
+ }).shape,
21
+ annotations: { readOnlyHint: true, idempotentHint: true },
22
+ };
23
+ export function makeAnalyticsHandler(client) {
24
+ return async (input) => {
25
+ const project = await resolveProjectByKey(client, input.projectKey);
26
+ let milestoneId;
27
+ if (input.milestone && (input.report === 'burndown' || input.report === 'earned-value' || input.report === 'forecast' || input.report === 'collaboration')) {
28
+ // ORB-1696 - shared resolver: key (ORB-M3), name or UUID, ambiguous
29
+ // name -> explicit error. Matches create_ticket/set_milestone/OQL.
30
+ const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
31
+ milestoneId = m.id;
32
+ }
33
+ const path = (() => {
34
+ switch (input.report) {
35
+ case 'earned-value': {
36
+ const qs = new URLSearchParams();
37
+ if (milestoneId)
38
+ qs.set('milestoneId', milestoneId);
39
+ if (input.mode)
40
+ qs.set('mode', input.mode);
41
+ return `/projects/${project.id}/earned-value${qs.toString() ? `?${qs}` : ''}`;
42
+ }
43
+ case 'burndown':
44
+ return `/projects/${project.id}/analytics/burndown${milestoneId ? `?milestoneId=${milestoneId}` : ''}`;
45
+ case 'forecast':
46
+ return `/projects/${project.id}/analytics/forecast${milestoneId ? `?milestoneId=${milestoneId}` : ''}`;
47
+ case 'collaboration':
48
+ return `/projects/${project.id}/analytics/collaboration${milestoneId ? `?milestoneId=${milestoneId}` : ''}`;
49
+ default:
50
+ return `/projects/${project.id}/analytics/${input.report}`;
51
+ }
52
+ })();
53
+ let data;
54
+ try {
55
+ data = await client.get(path);
56
+ }
57
+ catch (err) {
58
+ if (err instanceof OrbotoApiError && err.status === 403) {
59
+ const need = input.report === 'budget' || (input.report === 'earned-value' && input.mode === 'money') ? 'budget:view' : 'analytics:view';
60
+ return {
61
+ content: [{ type: 'text', text: `Not permitted: the ${input.report} report needs the ${need} permission on ${project.key}.` }],
62
+ structuredContent: { error: 'forbidden', report: input.report, requiredPermission: need },
63
+ };
64
+ }
65
+ throw err;
66
+ }
67
+ return {
68
+ content: [{ type: 'text', text: `${input.report} for ${project.key}:\n${JSON.stringify(data, null, 2)}` }],
69
+ structuredContent: { report: input.report, projectKey: project.key, data },
70
+ };
71
+ };
72
+ }