@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.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1805 - the schema diet must never cost a PARAMETER.
|
|
3
|
+
*
|
|
4
|
+
* The diet trims description text: tool prose moved into
|
|
5
|
+
* `orboto_help` / the skill's REFERENCE.md, per-parameter prose
|
|
6
|
+
* shortened to the phrase a caller acts on. What it must never do is
|
|
7
|
+
* drop a field, flip a required field to optional, or shrink an enum -
|
|
8
|
+
* those are the changes that silently break a caller.
|
|
9
|
+
*
|
|
10
|
+
* `tool-parameters.baseline.json` is the snapshot taken from the FULL
|
|
11
|
+
* manifest immediately BEFORE the ORB-1805 diet: for every tool, every
|
|
12
|
+
* property path in its input schema (nested objects and array items
|
|
13
|
+
* included, as dotted paths), its required list, and every enum's
|
|
14
|
+
* values. This test walks the live manifest the same way and fails on
|
|
15
|
+
* any loss.
|
|
16
|
+
*
|
|
17
|
+
* Direction matters: it is a SUPERSET check. Adding a parameter, making
|
|
18
|
+
* a required one optional-to-required is caught, and adding an enum
|
|
19
|
+
* value is fine - only removal fails. Re-baseline ONLY when a parameter
|
|
20
|
+
* is intentionally removed (which is an API break in its own right and
|
|
21
|
+
* belongs in its own ticket), never to make this test go quiet.
|
|
22
|
+
*/
|
|
23
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
27
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
28
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
29
|
+
const baseline = JSON.parse(readFileSync(fileURLToPath(new URL('./tool-parameters.baseline.json', import.meta.url)), 'utf8'));
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
|
|
33
|
+
delete process.env.ORBOTO_MCP_TOOLSET;
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
36
|
+
/** Same walk the baseline was generated with: dotted property paths,
|
|
37
|
+
* `[]` for an array hop, union branches flattened onto the same path. */
|
|
38
|
+
function walk(schema, prefix, params, enums) {
|
|
39
|
+
if (!schema || typeof schema !== 'object')
|
|
40
|
+
return;
|
|
41
|
+
if (Array.isArray(schema.enum) && prefix) {
|
|
42
|
+
(enums[prefix] ??= new Set());
|
|
43
|
+
for (const v of schema.enum)
|
|
44
|
+
enums[prefix].add(String(v));
|
|
45
|
+
}
|
|
46
|
+
for (const branch of [schema.anyOf, schema.oneOf, schema.allOf]) {
|
|
47
|
+
if (Array.isArray(branch))
|
|
48
|
+
for (const s of branch)
|
|
49
|
+
walk(s, prefix, params, enums);
|
|
50
|
+
}
|
|
51
|
+
if (schema.items)
|
|
52
|
+
walk(schema.items, `${prefix}[]`, params, enums);
|
|
53
|
+
if (schema.properties) {
|
|
54
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
55
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
56
|
+
params.add(p);
|
|
57
|
+
walk(v, p, params, enums);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function liveShapes() {
|
|
62
|
+
const server = await buildOrbotoMcpServer({
|
|
63
|
+
baseUrl: 'https://orboto.example.com',
|
|
64
|
+
apiKey: 'orb_test',
|
|
65
|
+
toolset: 'full',
|
|
66
|
+
});
|
|
67
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
68
|
+
const client = new Client({ name: 'param-shape', version: '0.0.0' });
|
|
69
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
70
|
+
try {
|
|
71
|
+
let cursor;
|
|
72
|
+
const tools = [];
|
|
73
|
+
do {
|
|
74
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
75
|
+
tools.push(...page.tools);
|
|
76
|
+
cursor = page.nextCursor;
|
|
77
|
+
} while (cursor);
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const t of tools) {
|
|
80
|
+
const params = new Set();
|
|
81
|
+
const enums = {};
|
|
82
|
+
walk(t.inputSchema, '', params, enums);
|
|
83
|
+
out[t.name] = {
|
|
84
|
+
params: [...params].sort(),
|
|
85
|
+
required: (t.inputSchema?.required ?? []).slice().sort(),
|
|
86
|
+
enums: Object.fromEntries(Object.entries(enums).map(([k, v]) => [k, [...v].sort()])),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await client.close();
|
|
93
|
+
await server.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
describe('ORB-1805 - no parameter lost to the schema diet', () => {
|
|
97
|
+
it('the baseline fixture is the pre-diet full manifest, not an empty file', () => {
|
|
98
|
+
expect(Object.keys(baseline).length).toBeGreaterThan(150);
|
|
99
|
+
expect(baseline.orboto_create_ticket?.params).toContain('duplicateJustification');
|
|
100
|
+
expect(baseline.orboto_update_ticket?.params).toContain('patch.customerSummary');
|
|
101
|
+
});
|
|
102
|
+
it('every tool in the baseline still exists', async () => {
|
|
103
|
+
const live = await liveShapes();
|
|
104
|
+
const missing = Object.keys(baseline).filter((t) => !live[t]);
|
|
105
|
+
expect(missing).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
it('every baseline parameter path still exists on its tool', async () => {
|
|
108
|
+
const live = await liveShapes();
|
|
109
|
+
const lost = [];
|
|
110
|
+
for (const [tool, shape] of Object.entries(baseline)) {
|
|
111
|
+
for (const p of shape.params) {
|
|
112
|
+
if (!live[tool]?.params.includes(p))
|
|
113
|
+
lost.push(`${tool}.${p}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
expect(lost).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
it('no required parameter became optional (and none appeared)', async () => {
|
|
119
|
+
const live = await liveShapes();
|
|
120
|
+
const changed = [];
|
|
121
|
+
for (const [tool, shape] of Object.entries(baseline)) {
|
|
122
|
+
const now = live[tool]?.required ?? [];
|
|
123
|
+
for (const r of shape.required)
|
|
124
|
+
if (!now.includes(r))
|
|
125
|
+
changed.push(`${tool}.${r} no longer required`);
|
|
126
|
+
for (const r of now)
|
|
127
|
+
if (!shape.required.includes(r))
|
|
128
|
+
changed.push(`${tool}.${r} newly required`);
|
|
129
|
+
}
|
|
130
|
+
expect(changed).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
it('no enum value disappeared', async () => {
|
|
133
|
+
const live = await liveShapes();
|
|
134
|
+
const lost = [];
|
|
135
|
+
for (const [tool, shape] of Object.entries(baseline)) {
|
|
136
|
+
for (const [path, values] of Object.entries(shape.enums)) {
|
|
137
|
+
const now = live[tool]?.enums[path] ?? [];
|
|
138
|
+
for (const v of values)
|
|
139
|
+
if (!now.includes(v))
|
|
140
|
+
lost.push(`${tool}.${path}: "${v}"`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
expect(lost).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-933 - write-side MCP tools for absences and the two admin tables
|
|
3
|
+
* that ride alongside them (public holidays, company closures).
|
|
4
|
+
*
|
|
5
|
+
* - orboto_update_public_holiday - PATCH /admin/public-holidays/:id
|
|
6
|
+
* - orboto_update_company_closure - PATCH /admin/company-closures/:id
|
|
7
|
+
* - orboto_update_absence - PATCH /absences/:id
|
|
8
|
+
*
|
|
9
|
+
* Today these surfaces only had POST + DELETE - typo correction or date
|
|
10
|
+
* fix meant delete + recreate, losing the original row id. PATCH fills
|
|
11
|
+
* that gap. Listing endpoints stay on the existing routes (no read tools
|
|
12
|
+
* here yet - that's Phase 3 of the CRUD parity epic, ORB-935).
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// orboto_update_public_holiday
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const updatePublicHolidayToolConfig = {
|
|
19
|
+
title: 'Edit a public holiday',
|
|
20
|
+
description: 'Correct a typo or wrong date on a public holiday without losing the row id. Admin-only (`admin:absences:write`). Pass only the fields you want to change; at least one field is required.',
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
id: z.string().uuid(),
|
|
23
|
+
name: z.string().min(1).max(100).optional(),
|
|
24
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
|
|
25
|
+
countryCode: z.string().max(10).nullable().optional(),
|
|
26
|
+
regionCode: z.string().max(10).nullable().optional(),
|
|
27
|
+
isRecurring: z.boolean().optional(),
|
|
28
|
+
}).shape,
|
|
29
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
30
|
+
};
|
|
31
|
+
export function makeUpdatePublicHolidayHandler(client) {
|
|
32
|
+
return async (input) => {
|
|
33
|
+
const { id, ...patch } = input;
|
|
34
|
+
const row = await client.patch(`/admin/public-holidays/${id}`, patch);
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: `Public holiday ${row.id} updated (${row.name} on ${row.date}).` }],
|
|
37
|
+
structuredContent: row,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// orboto_update_company_closure
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
export const updateCompanyClosureToolConfig = {
|
|
45
|
+
title: 'Edit a company closure',
|
|
46
|
+
description: 'Update a workspace-wide non-working block (company offsite, holiday shutdown). Admin-only (`admin:absences:write`). Pass only the fields you want to change; at least one field is required. If only one of `startDate`/`endDate` is passed, the other is left as-is - the API still validates start ≤ end after the partial patch.',
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
id: z.string().uuid(),
|
|
49
|
+
name: z.string().min(1).optional(),
|
|
50
|
+
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
|
|
51
|
+
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
|
|
52
|
+
note: z.string().nullish(),
|
|
53
|
+
}).shape,
|
|
54
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
55
|
+
};
|
|
56
|
+
export function makeUpdateCompanyClosureHandler(client) {
|
|
57
|
+
return async (input) => {
|
|
58
|
+
const { id, ...patch } = input;
|
|
59
|
+
const row = await client.patch(`/admin/company-closures/${id}`, patch);
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: `Company closure ${row.id} updated (${row.name}, ${row.startDate} → ${row.endDate}).` }],
|
|
62
|
+
structuredContent: row,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// orboto_update_absence
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
export const updateAbsenceToolConfig = {
|
|
70
|
+
title: 'Edit a pending or draft absence request',
|
|
71
|
+
description: 'Edit your own absence request before a reviewer acts on it. Locked once the status is `approved`, `rejected`, or `cancelled` - those transitions go through the approve / reject / cancel endpoints, not this one. Caller must be the absence owner or a super-admin. `durationDays` is recomputed automatically when dates move (uses the owner\'s working-day rules + holidays + closures).',
|
|
72
|
+
inputSchema: z.object({
|
|
73
|
+
id: z.string().uuid(),
|
|
74
|
+
typeId: z.string().uuid().optional().describe('Absence-type id (vacation, sick, etc.).'),
|
|
75
|
+
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
|
|
76
|
+
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
|
|
77
|
+
note: z.string().nullish(),
|
|
78
|
+
}).shape,
|
|
79
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
80
|
+
};
|
|
81
|
+
export function makeUpdateAbsenceHandler(client) {
|
|
82
|
+
return async (input) => {
|
|
83
|
+
const { id, ...patch } = input;
|
|
84
|
+
const row = await client.patch(`/absences/${id}`, patch);
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: `Absence ${row.id} updated (${row.startDate} → ${row.endDate}, ${row.durationDays} days, status=${row.status}).`,
|
|
89
|
+
}],
|
|
90
|
+
structuredContent: row,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-933 - tests for the PATCH-side MCP tools across the
|
|
3
|
+
* absences / public-holidays / company-closures resources.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
7
|
+
import { makeUpdatePublicHolidayHandler, makeUpdateCompanyClosureHandler, makeUpdateAbsenceHandler, } from './absence-writes.js';
|
|
8
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
10
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
11
|
+
const PH_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
|
|
12
|
+
const CC_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
|
|
13
|
+
const ABS_ID = 'cccccccc-0000-0000-0000-000000000001';
|
|
14
|
+
function stubJSON(responses) {
|
|
15
|
+
const calls = [];
|
|
16
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
17
|
+
calls.push({
|
|
18
|
+
url: url.toString(),
|
|
19
|
+
method: init?.method ?? 'GET',
|
|
20
|
+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
|
|
21
|
+
});
|
|
22
|
+
const r = responses.shift();
|
|
23
|
+
if (!r)
|
|
24
|
+
throw new Error('unexpected extra fetch');
|
|
25
|
+
return {
|
|
26
|
+
ok: r.ok ?? true,
|
|
27
|
+
status: r.status ?? 200,
|
|
28
|
+
statusText: 'OK',
|
|
29
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
30
|
+
text: async () => '',
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
return calls;
|
|
34
|
+
}
|
|
35
|
+
describe('orboto_update_public_holiday', () => {
|
|
36
|
+
it('PATCHes only the supplied fields', async () => {
|
|
37
|
+
const calls = stubJSON([{
|
|
38
|
+
json: { id: PH_ID, name: 'Day of German Unity', date: '2026-10-03', countryCode: 'DE', regionCode: null, isRecurring: true },
|
|
39
|
+
}]);
|
|
40
|
+
const res = await makeUpdatePublicHolidayHandler(client)({ id: PH_ID, date: '2026-10-03' });
|
|
41
|
+
expect(calls[0]).toMatchObject({
|
|
42
|
+
method: 'PATCH',
|
|
43
|
+
url: `https://orboto.example.com/admin/public-holidays/${PH_ID}`,
|
|
44
|
+
body: { date: '2026-10-03' },
|
|
45
|
+
});
|
|
46
|
+
expect(res.structuredContent.date).toBe('2026-10-03');
|
|
47
|
+
});
|
|
48
|
+
it('bubbles up a 403 when caller lacks admin:absences:write', async () => {
|
|
49
|
+
stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
|
|
50
|
+
await expect(makeUpdatePublicHolidayHandler(client)({ id: PH_ID, name: 'X' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('orboto_update_company_closure', () => {
|
|
54
|
+
it('PATCHes the named fields only', async () => {
|
|
55
|
+
const calls = stubJSON([{
|
|
56
|
+
json: { id: CC_ID, name: 'Summer break', startDate: '2026-07-20', endDate: '2026-08-02', note: null },
|
|
57
|
+
}]);
|
|
58
|
+
const res = await makeUpdateCompanyClosureHandler(client)({ id: CC_ID, endDate: '2026-08-02' });
|
|
59
|
+
expect(calls[0]).toMatchObject({
|
|
60
|
+
method: 'PATCH',
|
|
61
|
+
url: `https://orboto.example.com/admin/company-closures/${CC_ID}`,
|
|
62
|
+
body: { endDate: '2026-08-02' },
|
|
63
|
+
});
|
|
64
|
+
expect(res.structuredContent.endDate).toBe('2026-08-02');
|
|
65
|
+
});
|
|
66
|
+
it('bubbles up a 400 when start > end after the partial patch', async () => {
|
|
67
|
+
stubJSON([{ ok: false, status: 400, json: { error: 'Start date must be before end date' } }]);
|
|
68
|
+
await expect(makeUpdateCompanyClosureHandler(client)({ id: CC_ID, startDate: '2026-09-01' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('orboto_update_absence', () => {
|
|
72
|
+
it('PATCHes typeId + dates and surfaces the recomputed durationDays', async () => {
|
|
73
|
+
const calls = stubJSON([{
|
|
74
|
+
json: {
|
|
75
|
+
id: ABS_ID,
|
|
76
|
+
userId: 'u1',
|
|
77
|
+
typeId: 'aaaaaaaa-1111-1111-1111-111111111111',
|
|
78
|
+
startDate: '2026-08-01',
|
|
79
|
+
endDate: '2026-08-07',
|
|
80
|
+
durationDays: '5.0',
|
|
81
|
+
status: 'pending',
|
|
82
|
+
note: 'family trip',
|
|
83
|
+
},
|
|
84
|
+
}]);
|
|
85
|
+
const res = await makeUpdateAbsenceHandler(client)({
|
|
86
|
+
id: ABS_ID,
|
|
87
|
+
startDate: '2026-08-01',
|
|
88
|
+
endDate: '2026-08-07',
|
|
89
|
+
note: 'family trip',
|
|
90
|
+
});
|
|
91
|
+
expect(calls[0]).toMatchObject({
|
|
92
|
+
method: 'PATCH',
|
|
93
|
+
url: `https://orboto.example.com/absences/${ABS_ID}`,
|
|
94
|
+
body: { startDate: '2026-08-01', endDate: '2026-08-07', note: 'family trip' },
|
|
95
|
+
});
|
|
96
|
+
expect(res.structuredContent.durationDays).toBe('5.0');
|
|
97
|
+
});
|
|
98
|
+
it('bubbles up a 400 when the absence is already approved (status-locked)', async () => {
|
|
99
|
+
stubJSON([{ ok: false, status: 400, json: { error: 'Can only edit pending or draft absences' } }]);
|
|
100
|
+
await expect(makeUpdateAbsenceHandler(client)({ id: ABS_ID, note: 'late change' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
101
|
+
});
|
|
102
|
+
it('bubbles up a 403 when caller is not the owner', async () => {
|
|
103
|
+
stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
|
|
104
|
+
await expect(makeUpdateAbsenceHandler(client)({ id: ABS_ID, note: 'x' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-893 - admin-translation review queue MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Three thin wrappers around `/admin/tickets/translations*`:
|
|
5
|
+
*
|
|
6
|
+
* - `orboto_admin_translation_list` - paginated list of
|
|
7
|
+
* auto-translated tickets (pending review by default, or `all`).
|
|
8
|
+
* - `orboto_admin_translation_approve` - stamp the row reviewed.
|
|
9
|
+
* - `orboto_admin_translation_revert` - restore the pre-translation
|
|
10
|
+
* title + description from the audit comment.
|
|
11
|
+
*
|
|
12
|
+
* All three require `admin:translation_review` (super-admin holds it
|
|
13
|
+
* by default).
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
export const listAdminTranslationsToolConfig = {
|
|
17
|
+
title: 'List auto-translated tickets for review',
|
|
18
|
+
description: 'List tickets the AI auto-translate flow rewrote, sorted newest-updated first. Default surface is the pending queue (tickets the admin has not yet approved). Pass `status: "all"` to include reviewed rows too. Returns up to `limit` (default 25) rows + a `nextCursor` for paging. Requires `admin:translation_review`.',
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
status: z.enum(['pending', 'all']).optional().describe('Default: pending.'),
|
|
21
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default: 25.'),
|
|
22
|
+
cursor: z.string().optional().describe('Opaque cursor from a previous response.'),
|
|
23
|
+
}).shape,
|
|
24
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
25
|
+
};
|
|
26
|
+
export function makeListAdminTranslationsHandler(client) {
|
|
27
|
+
return async ({ status, limit, cursor }) => {
|
|
28
|
+
const qs = new URLSearchParams();
|
|
29
|
+
if (status)
|
|
30
|
+
qs.set('status', status);
|
|
31
|
+
if (limit)
|
|
32
|
+
qs.set('limit', String(limit));
|
|
33
|
+
if (cursor)
|
|
34
|
+
qs.set('cursor', cursor);
|
|
35
|
+
const path = `/admin/tickets/translations${qs.toString() ? `?${qs.toString()}` : ''}`;
|
|
36
|
+
const res = await client.get(path);
|
|
37
|
+
const lines = res.items.length === 0
|
|
38
|
+
? '(no auto-translated tickets)'
|
|
39
|
+
: res.items.map((r) => {
|
|
40
|
+
const reviewer = r.reviewedBy ? ` ✓ ${r.reviewedBy.fullName ?? r.reviewedBy.email}` : ' (pending review)';
|
|
41
|
+
const key = r.ticketKey ?? r.id.slice(0, 8);
|
|
42
|
+
return `- [${key}] ${r.title} (${r.autoTranslatedFrom} → ${r.defaultLocale}${reviewer})`;
|
|
43
|
+
}).join('\n');
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: 'text', text: lines + (res.nextCursor ? `\n\n(next cursor: ${res.nextCursor})` : '') }],
|
|
46
|
+
structuredContent: { items: res.items, nextCursor: res.nextCursor },
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export const approveTranslationToolConfig = {
|
|
51
|
+
title: 'Approve an auto-translated ticket',
|
|
52
|
+
description: 'Stamp `translation_reviewed_at` + `translation_reviewed_by` on a ticket the AI auto-translate flow rewrote. Idempotent - re-approving the same row simply refreshes the timestamp + reviewer. 409 if the ticket was never auto-translated. Requires `admin:translation_review`.',
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
ticketId: z.string().uuid().describe('UUID of the ticket to approve.'),
|
|
55
|
+
}).shape,
|
|
56
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
57
|
+
};
|
|
58
|
+
export function makeApproveTranslationHandler(client) {
|
|
59
|
+
return async ({ ticketId }) => {
|
|
60
|
+
const res = await client.post(`/admin/tickets/${ticketId}/translations/approve`, {});
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: 'text', text: `Approved translation for ticket ${ticketId} at ${res.reviewedAt}.` }],
|
|
63
|
+
structuredContent: res,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const revertTranslationToolConfig = {
|
|
68
|
+
title: 'Revert an auto-translated ticket back to the original',
|
|
69
|
+
description: 'Restore the pre-translation title + description from the auto-translated audit comment and clear the auto-translate marker + any prior approval. Use when the AI got the translation wrong and the operator wants to start over. 409 if the ticket was never auto-translated. Requires `admin:translation_review`.',
|
|
70
|
+
inputSchema: z.object({
|
|
71
|
+
ticketId: z.string().uuid().describe('UUID of the ticket to revert.'),
|
|
72
|
+
}).shape,
|
|
73
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
74
|
+
};
|
|
75
|
+
export function makeAdminRevertTranslationHandler(client) {
|
|
76
|
+
return async ({ ticketId }) => {
|
|
77
|
+
const res = await client.post(`/admin/tickets/${ticketId}/translations/revert`, {});
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: `Reverted ticket ${ticketId} to its original content.` }],
|
|
80
|
+
structuredContent: res,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 4 - admin-only tools.
|
|
3
|
+
*
|
|
4
|
+
* These three are listed under "write tools" in the ticket because
|
|
5
|
+
* they're privileged surfaces, but two of them (`list_users`,
|
|
6
|
+
* `get_audit_log`) are read-only - the gating is what makes them
|
|
7
|
+
* "writes" from a permission perspective. `trigger_backup` is the
|
|
8
|
+
* only true mutation.
|
|
9
|
+
*
|
|
10
|
+
* Gating: the API enforces super-admin via the `admin:*` permission
|
|
11
|
+
* slugs. A non-admin's request lands a 403 from the API, which
|
|
12
|
+
* surfaces as OrbotoApiError on the MCP side. The tools themselves
|
|
13
|
+
* don't double-check - that would race against the API anyway. We
|
|
14
|
+
* just rewrite 403 into a clear message.
|
|
15
|
+
*
|
|
16
|
+
* Tools:
|
|
17
|
+
* - orboto_list_users - admin user directory with cursor pagination
|
|
18
|
+
* - orboto_get_audit_log - recent admin / mutation events
|
|
19
|
+
* - orboto_trigger_backup - run a configured backup job by name
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
23
|
+
function rewrite403(action) {
|
|
24
|
+
return (err) => {
|
|
25
|
+
if (err instanceof OrbotoApiError && err.status === 403) {
|
|
26
|
+
throw new Error(`${action} requires super-admin permissions on this workspace.`);
|
|
27
|
+
}
|
|
28
|
+
throw err;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// orboto_list_users
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export const listUsersToolConfig = {
|
|
35
|
+
title: 'List workspace users (admin)',
|
|
36
|
+
description: 'Return the workspace user directory. Super-admin only. Useful for AI agents that need to look up an email beyond their visible project members (e.g. to assign someone who isn\'t on the current project yet). Imported placeholder users (`*@imported.ghost`) are hidden by default; set `showGhosts=true` to include them.',
|
|
37
|
+
inputSchema: z.object({
|
|
38
|
+
search: z.string().optional().describe('Substring match against fullName + email.'),
|
|
39
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
40
|
+
showGhosts: z.boolean().optional().describe('Include `*@imported.ghost` placeholder users created during project imports. Off by default.'),
|
|
41
|
+
}).shape,
|
|
42
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
43
|
+
};
|
|
44
|
+
export function makeListUsersHandler(client) {
|
|
45
|
+
return async ({ search, limit, showGhosts }) => {
|
|
46
|
+
const qs = new URLSearchParams();
|
|
47
|
+
qs.set('limit', String(limit ?? 50));
|
|
48
|
+
if (search)
|
|
49
|
+
qs.set('search', search);
|
|
50
|
+
if (showGhosts)
|
|
51
|
+
qs.set('hideGhosts', 'false');
|
|
52
|
+
const page = await client.get(`/admin/users?${qs}`).catch(rewrite403('list_users'));
|
|
53
|
+
const text = page.items.length === 0
|
|
54
|
+
? 'No users matched.'
|
|
55
|
+
: page.items.map((u) => {
|
|
56
|
+
const tags = [];
|
|
57
|
+
if (u.isBot)
|
|
58
|
+
tags.push('bot');
|
|
59
|
+
if (u.isExternal)
|
|
60
|
+
tags.push('external');
|
|
61
|
+
if (!u.isActive)
|
|
62
|
+
tags.push('disabled');
|
|
63
|
+
return `- ${u.fullName} <${u.email}>${tags.length ? ` [${tags.join(', ')}]` : ''}`;
|
|
64
|
+
}).join('\n');
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text }],
|
|
67
|
+
structuredContent: {
|
|
68
|
+
count: page.items.length,
|
|
69
|
+
hasMore: !!page.nextCursor,
|
|
70
|
+
users: page.items.map((u) => ({
|
|
71
|
+
email: u.email,
|
|
72
|
+
fullName: u.fullName,
|
|
73
|
+
isActive: u.isActive,
|
|
74
|
+
isExternal: u.isExternal,
|
|
75
|
+
isBot: u.isBot,
|
|
76
|
+
lastSeenAt: u.lastSeenAt ?? null,
|
|
77
|
+
})),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// orboto_get_audit_log
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
export const getAuditLogToolConfig = {
|
|
86
|
+
title: 'Read the workspace audit log (admin)',
|
|
87
|
+
description: 'Recent audit events - admin actions, role changes, ticket deletions, federation handshakes, etc. Super-admin only. Filter by actor or entity to narrow the window.',
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
actorEmail: z.string().email().optional().describe('Filter to one actor.'),
|
|
90
|
+
entityType: z.string().optional().describe('e.g. "user", "project", "ticket", "federation_link".'),
|
|
91
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
92
|
+
}).shape,
|
|
93
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
94
|
+
};
|
|
95
|
+
export function makeGetAuditLogHandler(client) {
|
|
96
|
+
return async ({ actorEmail, entityType, limit }) => {
|
|
97
|
+
const qs = new URLSearchParams();
|
|
98
|
+
qs.set('limit', String(limit ?? 50));
|
|
99
|
+
if (entityType)
|
|
100
|
+
qs.set('entityType', entityType);
|
|
101
|
+
if (actorEmail) {
|
|
102
|
+
// Resolve actor email → userId via the admin users list.
|
|
103
|
+
const userPage = await client.get(`/admin/users?search=${encodeURIComponent(actorEmail)}&limit=10`).catch(rewrite403('audit log lookup'));
|
|
104
|
+
const actor = userPage.items.find((u) => u.email.toLowerCase() === actorEmail.toLowerCase());
|
|
105
|
+
if (!actor)
|
|
106
|
+
throw new Error(`No workspace user with email "${actorEmail}".`);
|
|
107
|
+
qs.set('actorId', actor.id);
|
|
108
|
+
}
|
|
109
|
+
const page = await client.get(`/admin/audit-log?${qs}`).catch(rewrite403('audit log'));
|
|
110
|
+
const text = page.items.length === 0
|
|
111
|
+
? 'No matching audit entries.'
|
|
112
|
+
: page.items.map((e) => {
|
|
113
|
+
const who = e.actorName ?? e.actorEmail ?? '(system)';
|
|
114
|
+
// ORB-1368 - mark agent-performed actions.
|
|
115
|
+
const agentMark = e.isAgentWork ? ' [agent]' : '';
|
|
116
|
+
const what = e.entityType
|
|
117
|
+
? ` ${e.entityType}${e.entityId ? `:${e.entityId.slice(0, 8)}` : ''}`
|
|
118
|
+
: '';
|
|
119
|
+
return `- ${e.createdAt} · ${who}${agentMark} → ${e.action}${what}`;
|
|
120
|
+
}).join('\n');
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: 'text', text }],
|
|
123
|
+
structuredContent: {
|
|
124
|
+
count: page.items.length,
|
|
125
|
+
hasMore: !!page.nextCursor,
|
|
126
|
+
entries: page.items.map((e) => ({
|
|
127
|
+
actorEmail: e.actorEmail,
|
|
128
|
+
action: e.action,
|
|
129
|
+
entityType: e.entityType,
|
|
130
|
+
entityId: e.entityId,
|
|
131
|
+
createdAt: e.createdAt,
|
|
132
|
+
})),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// orboto_trigger_backup
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
export const triggerBackupToolConfig = {
|
|
141
|
+
title: 'Run a configured backup job now (admin)',
|
|
142
|
+
description: 'Manually trigger a backup job by name. Resolves to the job\'s UUID via /admin/backup/jobs and POSTs to the run endpoint. Super-admin only.',
|
|
143
|
+
inputSchema: z.object({
|
|
144
|
+
jobName: z.string().min(1).describe('Name of an existing backup job (configured at /admin/backup).'),
|
|
145
|
+
}).shape,
|
|
146
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
147
|
+
};
|
|
148
|
+
export function makeTriggerBackupHandler(client) {
|
|
149
|
+
return async ({ jobName }) => {
|
|
150
|
+
const jobs = await client.get('/admin/backup/jobs')
|
|
151
|
+
.catch(rewrite403('list_backup_jobs'));
|
|
152
|
+
const job = jobs.find((j) => j.name === jobName);
|
|
153
|
+
if (!job) {
|
|
154
|
+
throw new Error(`No backup job named "${jobName}". Existing jobs: ${jobs.map((j) => `"${j.name}"`).join(', ') || '(none)'}`);
|
|
155
|
+
}
|
|
156
|
+
if (!job.isActive) {
|
|
157
|
+
throw new Error(`Backup job "${jobName}" is currently disabled - enable it in /admin/backup first.`);
|
|
158
|
+
}
|
|
159
|
+
const run = await client.post(`/admin/backup/jobs/${job.id}/run`, {})
|
|
160
|
+
.catch(rewrite403('trigger_backup'));
|
|
161
|
+
return {
|
|
162
|
+
content: [{
|
|
163
|
+
type: 'text',
|
|
164
|
+
text: `Started backup job "${jobName}" (run id ${run.id.slice(0, 8)}, status: ${run.status}).`,
|
|
165
|
+
}],
|
|
166
|
+
structuredContent: {
|
|
167
|
+
jobName,
|
|
168
|
+
runId: run.id,
|
|
169
|
+
status: run.status,
|
|
170
|
+
startedAt: run.startedAt,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
}
|