@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,71 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
3
|
+
import { makeAnalyticsHandler } from './analytics.js';
|
|
4
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
5
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
6
|
+
function stub(responses) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
9
|
+
calls.push(url.toString());
|
|
10
|
+
const r = responses.shift();
|
|
11
|
+
if (!r)
|
|
12
|
+
throw new Error(`unexpected extra fetch to ${url}`);
|
|
13
|
+
const status = r.status ?? 200;
|
|
14
|
+
return { ok: status < 400, status, statusText: 'x', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
|
|
15
|
+
});
|
|
16
|
+
return calls;
|
|
17
|
+
}
|
|
18
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
19
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
|
|
20
|
+
describe('orboto_analytics (ORB-1032)', () => {
|
|
21
|
+
it('routes a plain report to the analytics endpoint', async () => {
|
|
22
|
+
const calls = stub([{ json: PROJ }, { json: { total: 5 } }]);
|
|
23
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'overview' });
|
|
24
|
+
expect(calls[1]).toContain('/projects/p1/analytics/overview');
|
|
25
|
+
expect(res.structuredContent).toMatchObject({ report: 'overview', projectKey: 'ACME', data: { total: 5 } });
|
|
26
|
+
});
|
|
27
|
+
it('routes estimation-accuracy to the analytics endpoint (ORB-1254)', async () => {
|
|
28
|
+
const calls = stub([{ json: PROJ }, { json: { cohorts: { combined: { confidence: 'insufficient' } } } }]);
|
|
29
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'estimation-accuracy' });
|
|
30
|
+
expect(calls[1]).toContain('/projects/p1/analytics/estimation-accuracy');
|
|
31
|
+
expect(res.structuredContent).toMatchObject({ report: 'estimation-accuracy', projectKey: 'ACME' });
|
|
32
|
+
});
|
|
33
|
+
it('routes flow-time to the analytics endpoint (ORB-1257)', async () => {
|
|
34
|
+
const calls = stub([{ json: PROJ }, { json: { overall: { sampleSize: 0 } } }]);
|
|
35
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'flow-time' });
|
|
36
|
+
expect(calls[1]).toContain('/projects/p1/analytics/flow-time');
|
|
37
|
+
expect(res.structuredContent).toMatchObject({ report: 'flow-time', projectKey: 'ACME' });
|
|
38
|
+
});
|
|
39
|
+
it('routes flow-metrics to the analytics endpoint (ORB-1258)', async () => {
|
|
40
|
+
const calls = stub([{ json: PROJ }, { json: { currentWip: 0 } }]);
|
|
41
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'flow-metrics' });
|
|
42
|
+
expect(calls[1]).toContain('/projects/p1/analytics/flow-metrics');
|
|
43
|
+
expect(res.structuredContent).toMatchObject({ report: 'flow-metrics', projectKey: 'ACME' });
|
|
44
|
+
});
|
|
45
|
+
it('routes forecast (with milestone) to the forecast endpoint (ORB-1259)', async () => {
|
|
46
|
+
const calls = stub([{ json: PROJ }, { json: [{ id: 'm9', name: 'Sprint 9' }] }, { json: { confidence: 'ok' } }]);
|
|
47
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'forecast', milestone: 'Sprint 9' });
|
|
48
|
+
expect(calls[2]).toContain('/projects/p1/analytics/forecast');
|
|
49
|
+
expect(calls[2]).toContain('milestoneId=m9');
|
|
50
|
+
expect(res.structuredContent).toMatchObject({ report: 'forecast', projectKey: 'ACME' });
|
|
51
|
+
});
|
|
52
|
+
it('routes bottleneck to the analytics endpoint (ORB-1260)', async () => {
|
|
53
|
+
const calls = stub([{ json: PROJ }, { json: { bottleneckCategory: 'in_review' } }]);
|
|
54
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'bottleneck' });
|
|
55
|
+
expect(calls[1]).toContain('/projects/p1/analytics/bottleneck');
|
|
56
|
+
expect(res.structuredContent).toMatchObject({ report: 'bottleneck', projectKey: 'ACME' });
|
|
57
|
+
});
|
|
58
|
+
it('resolves a milestone + mode for earned-value', async () => {
|
|
59
|
+
const calls = stub([{ json: PROJ }, { json: [{ id: 'm2', name: 'Sprint 7' }] }, { json: { available: true } }]);
|
|
60
|
+
await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'earned-value', milestone: 'Sprint 7', mode: 'money' });
|
|
61
|
+
expect(calls[2]).toContain('/projects/p1/earned-value');
|
|
62
|
+
expect(calls[2]).toContain('milestoneId=m2');
|
|
63
|
+
expect(calls[2]).toContain('mode=money');
|
|
64
|
+
});
|
|
65
|
+
it('surfaces a permission error as a forbidden envelope (not a throw)', async () => {
|
|
66
|
+
stub([{ json: PROJ }, { status: 403 }]);
|
|
67
|
+
const res = await makeAnalyticsHandler(client)({ projectKey: 'ACME', report: 'budget' });
|
|
68
|
+
expect(res.structuredContent).toMatchObject({ error: 'forbidden', requiredPermission: 'budget:view' });
|
|
69
|
+
expect(res.content[0].text).toMatch(/budget:view/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1519 - `orboto_api_call`: the execute half of the Code-Mode
|
|
3
|
+
* escape hatch (epic ORB-1517).
|
|
4
|
+
*
|
|
5
|
+
* Wraps `POST /system/api-proxy`, which dispatches the described
|
|
6
|
+
* request through the API's full auth + permission + validation chain
|
|
7
|
+
* with the caller's own bearer. The inner status/body come back as data
|
|
8
|
+
* (the envelope), so a 403 tells the model it lacks a permission
|
|
9
|
+
* instead of surfacing as an opaque tool failure.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
export const apiCallToolConfig = {
|
|
13
|
+
title: 'Call any orboto REST endpoint',
|
|
14
|
+
description: 'Execute one REST API request - the escape hatch for every endpoint without a named tool. '
|
|
15
|
+
+ 'Find the endpoint + its schema with `orboto_api_search` first. Runs with YOUR permissions '
|
|
16
|
+
+ 'only (the API enforces them server-side); the response is an envelope carrying the inner '
|
|
17
|
+
+ 'HTTP status + body verbatim, so react to a 403/404/422 as data. Auth/OAuth/setup/webhook '
|
|
18
|
+
+ 'paths are blocked at the proxy. Prefer named tools when one exists - they are more ergonomic.',
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
|
21
|
+
path: z.string().min(1).max(500)
|
|
22
|
+
.describe('Route path, e.g. "/projects/<uuid>/milestones". No query string.'),
|
|
23
|
+
query: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional()
|
|
24
|
+
.describe('Query parameters as an object.'),
|
|
25
|
+
// ORB-1710 - typed union, NOT z.unknown(): a typeless argument is
|
|
26
|
+
// serialized as a STRING by real MCP clients (the OCP-540 class),
|
|
27
|
+
// which used to reach the inner route without a JSON content type
|
|
28
|
+
// and 415. The union renders a typed anyOf so clients send real
|
|
29
|
+
// JSON; the handler still parses a string arrival as belt-and-braces.
|
|
30
|
+
body: z.union([z.record(z.unknown()), z.array(z.unknown()), z.string(), z.number(), z.boolean()]).optional()
|
|
31
|
+
.describe('JSON body - a real object/array, not a stringified one.'),
|
|
32
|
+
}).shape,
|
|
33
|
+
// Dispatches arbitrary methods - never advertise as read-only.
|
|
34
|
+
// ORB-1669 - the escape hatch can issue DELETE through the proxy: destructive by contract.
|
|
35
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
36
|
+
};
|
|
37
|
+
export function makeApiCallHandler(client) {
|
|
38
|
+
return async (input) => {
|
|
39
|
+
// ORB-1710 - belt-and-braces for clients that still stringify the
|
|
40
|
+
// body: a string that parses as JSON is un-stringified here, so the
|
|
41
|
+
// proxy receives real JSON even against an older API without the
|
|
42
|
+
// server-side coercion. An unparseable string passes through and
|
|
43
|
+
// gets the proxy's clear 400.
|
|
44
|
+
let body = input.body;
|
|
45
|
+
if (typeof body === 'string') {
|
|
46
|
+
try {
|
|
47
|
+
body = JSON.parse(body);
|
|
48
|
+
}
|
|
49
|
+
catch { /* pass through */ }
|
|
50
|
+
}
|
|
51
|
+
const envelope = await client.post('/system/api-proxy', {
|
|
52
|
+
method: input.method,
|
|
53
|
+
path: input.path,
|
|
54
|
+
...(input.query ? { query: input.query } : {}),
|
|
55
|
+
...(body !== undefined ? { body } : {}),
|
|
56
|
+
});
|
|
57
|
+
const bodyText = envelope.encoding === 'json'
|
|
58
|
+
? JSON.stringify(envelope.body, null, 1)
|
|
59
|
+
: String(envelope.body ?? '');
|
|
60
|
+
const truncNote = envelope.truncated ? ' (body truncated by the proxy cap)' : '';
|
|
61
|
+
const okNote = envelope.status >= 400
|
|
62
|
+
? ' - the API rejected the inner request; read the body for why (403 = missing permission).'
|
|
63
|
+
: '';
|
|
64
|
+
return {
|
|
65
|
+
content: [{
|
|
66
|
+
type: 'text',
|
|
67
|
+
text: `HTTP ${envelope.status} ${envelope.matchedRoute}${truncNote}${okNote}\n${bodyText}`,
|
|
68
|
+
}],
|
|
69
|
+
structuredContent: envelope,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1519 - unit tests for `orboto_api_call`.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeApiCallHandler } from './api-call.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
calls.push({
|
|
13
|
+
url: url.toString(),
|
|
14
|
+
method: init?.method ?? 'GET',
|
|
15
|
+
body: init?.body ? JSON.parse(init.body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const r = responses.shift();
|
|
18
|
+
if (!r)
|
|
19
|
+
throw new Error('unexpected extra fetch');
|
|
20
|
+
return {
|
|
21
|
+
ok: r.ok ?? true,
|
|
22
|
+
status: r.status ?? 200,
|
|
23
|
+
statusText: 'OK',
|
|
24
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
25
|
+
text: async () => r.text ?? '',
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
return calls;
|
|
29
|
+
}
|
|
30
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
31
|
+
describe('orboto_api_call', () => {
|
|
32
|
+
it('POSTs the structured request to /system/api-proxy and renders the envelope', async () => {
|
|
33
|
+
const calls = stub([{
|
|
34
|
+
json: {
|
|
35
|
+
status: 201,
|
|
36
|
+
contentType: 'application/json',
|
|
37
|
+
body: { id: 'm1', name: 'M' },
|
|
38
|
+
encoding: 'json',
|
|
39
|
+
truncated: false,
|
|
40
|
+
matchedRoute: '/projects/{projectId}/milestones',
|
|
41
|
+
},
|
|
42
|
+
}]);
|
|
43
|
+
const res = await makeApiCallHandler(client)({
|
|
44
|
+
method: 'POST',
|
|
45
|
+
path: '/projects/p1/milestones',
|
|
46
|
+
body: { name: 'M', startDate: null, endDate: null },
|
|
47
|
+
});
|
|
48
|
+
expect(calls[0].url).toContain('/system/api-proxy');
|
|
49
|
+
expect(calls[0].body).toMatchObject({ method: 'POST', path: '/projects/p1/milestones' });
|
|
50
|
+
expect(res.isError).toBeUndefined();
|
|
51
|
+
expect(res.content[0].text).toContain('HTTP 201');
|
|
52
|
+
expect(res.structuredContent.status).toBe(201);
|
|
53
|
+
});
|
|
54
|
+
it('surfaces an inner 403 as data with a permission hint', async () => {
|
|
55
|
+
stub([{
|
|
56
|
+
json: {
|
|
57
|
+
status: 403,
|
|
58
|
+
contentType: 'application/json',
|
|
59
|
+
body: { error: 'Forbidden', errorKey: 'errors.permissions.forbidden' },
|
|
60
|
+
encoding: 'json',
|
|
61
|
+
truncated: false,
|
|
62
|
+
matchedRoute: '/projects/{projectId}/milestones',
|
|
63
|
+
},
|
|
64
|
+
}]);
|
|
65
|
+
const res = await makeApiCallHandler(client)({ method: 'POST', path: '/projects/p1/milestones', body: {} });
|
|
66
|
+
expect(res.isError).toBeUndefined(); // the tool call succeeded; the API said no
|
|
67
|
+
const text = res.content[0].text;
|
|
68
|
+
expect(text).toContain('HTTP 403');
|
|
69
|
+
expect(text).toMatch(/missing permission/i);
|
|
70
|
+
});
|
|
71
|
+
it('un-stringifies a client-stringified JSON body before posting (ORB-1710)', async () => {
|
|
72
|
+
const calls = stub([{
|
|
73
|
+
json: { status: 201, contentType: 'application/json', body: { ok: true }, encoding: 'json', truncated: false, matchedRoute: '/docs/{id}/append-section' },
|
|
74
|
+
}]);
|
|
75
|
+
await makeApiCallHandler(client)({
|
|
76
|
+
method: 'POST',
|
|
77
|
+
path: '/docs/abc/append-section',
|
|
78
|
+
body: '{"content":"# heading"}',
|
|
79
|
+
});
|
|
80
|
+
// The proxy must receive real JSON, not a double-encoded string.
|
|
81
|
+
expect(calls[0].body.body).toEqual({ content: '# heading' });
|
|
82
|
+
});
|
|
83
|
+
it('passes an unparseable string body through for the proxy\'s clear 400', async () => {
|
|
84
|
+
const calls = stub([{
|
|
85
|
+
json: { status: 400, contentType: 'application/json', body: { errorKey: 'errors.api_proxy.invalid_body' }, encoding: 'json', truncated: false, matchedRoute: '/x' },
|
|
86
|
+
}]);
|
|
87
|
+
await makeApiCallHandler(client)({ method: 'POST', path: '/projects', body: 'not json' });
|
|
88
|
+
expect(calls[0].body.body).toBe('not json');
|
|
89
|
+
});
|
|
90
|
+
it('forwards query params', async () => {
|
|
91
|
+
const calls = stub([{
|
|
92
|
+
json: { status: 200, contentType: 'application/json', body: [], encoding: 'json', truncated: false, matchedRoute: '/projects' },
|
|
93
|
+
}]);
|
|
94
|
+
await makeApiCallHandler(client)({ method: 'GET', path: '/projects', query: { limit: 5 } });
|
|
95
|
+
expect(calls[0].body).toMatchObject({ query: { limit: 5 } });
|
|
96
|
+
});
|
|
97
|
+
it('marks a truncated envelope in the text output', async () => {
|
|
98
|
+
stub([{
|
|
99
|
+
json: { status: 200, contentType: 'text/plain', body: 'x'.repeat(50), encoding: 'utf8', truncated: true, matchedRoute: '/export' },
|
|
100
|
+
}]);
|
|
101
|
+
const res = await makeApiCallHandler(client)({ method: 'GET', path: '/export' });
|
|
102
|
+
expect(res.content[0].text).toContain('truncated by the proxy cap');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1518 - `orboto_api_search`: the discovery half of the Code-Mode
|
|
3
|
+
* escape hatch (epic ORB-1517).
|
|
4
|
+
*
|
|
5
|
+
* Wraps the authenticated `GET /system/api-catalog` routes, which search
|
|
6
|
+
* the live OpenAPI spec server-side. Lets an agent find any REST
|
|
7
|
+
* endpoint + its schema on demand instead of needing a named MCP tool
|
|
8
|
+
* per endpoint - the long tail of the API stays reachable even on a
|
|
9
|
+
* curated (small) tool manifest. Execute the endpoint you found with
|
|
10
|
+
* `orboto_api_call` (ORB-1519).
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
export const apiSearchToolConfig = {
|
|
14
|
+
title: 'Search the orboto REST API catalog',
|
|
15
|
+
description: 'Discover REST API endpoints on demand - the escape hatch that keeps the WHOLE orboto API '
|
|
16
|
+
+ 'reachable without a named tool per endpoint. Two modes: pass `query` (free text, e.g. '
|
|
17
|
+
+ '"create milestone", "backup runs", "primer fact") for ranked matches with method, path, '
|
|
18
|
+
+ 'required permission slugs and parameter names; then pass `path` + `method` of one match to '
|
|
19
|
+
+ 'get its full parameter/request-body/response schema. Execute the endpoint with '
|
|
20
|
+
+ '`orboto_api_call`. Names with a `?` suffix are optional parameters.',
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
query: z.string().min(1).max(200).optional()
|
|
23
|
+
.describe('Free-text search; mutually exclusive with path.'),
|
|
24
|
+
path: z.string().max(300).optional()
|
|
25
|
+
.describe('Path for detail mode, e.g. "/projects/{id}". Needs method.'),
|
|
26
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional()
|
|
27
|
+
.describe('Method for detail mode.'),
|
|
28
|
+
limit: z.number().int().min(1).max(20).default(8)
|
|
29
|
+
.describe('Max search results.'),
|
|
30
|
+
}).shape,
|
|
31
|
+
annotations: { readOnlyHint: true },
|
|
32
|
+
};
|
|
33
|
+
export function makeApiSearchHandler(client) {
|
|
34
|
+
return async (input) => {
|
|
35
|
+
if (input.path) {
|
|
36
|
+
if (!input.method) {
|
|
37
|
+
return {
|
|
38
|
+
isError: true,
|
|
39
|
+
content: [{ type: 'text', text: 'Detail mode needs both `path` and `method`.' }],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const qs = new URLSearchParams({ path: input.path, method: input.method });
|
|
43
|
+
const detail = await client.get(`/system/api-catalog/operation?${qs}`);
|
|
44
|
+
const perms = detail.requiredPermissions.length > 0
|
|
45
|
+
? ` (requires: ${detail.requiredPermissions.join(', ')})`
|
|
46
|
+
: '';
|
|
47
|
+
return {
|
|
48
|
+
content: [{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `${detail.method} ${detail.path}${perms}\n${detail.summary ?? ''}\n`
|
|
51
|
+
+ `Schema:\n${JSON.stringify({ parameters: detail.parameters, requestBody: detail.requestBody, responses: detail.responses }, null, 1)}`,
|
|
52
|
+
}],
|
|
53
|
+
structuredContent: detail,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (!input.query) {
|
|
57
|
+
return {
|
|
58
|
+
isError: true,
|
|
59
|
+
content: [{ type: 'text', text: 'Pass `query` to search, or `path` + `method` for one endpoint\'s schema.' }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const qs = new URLSearchParams({ q: input.query, limit: String(input.limit ?? 8) });
|
|
63
|
+
const res = await client.get(`/system/api-catalog?${qs}`);
|
|
64
|
+
const text = res.results.length === 0
|
|
65
|
+
? `No endpoints match "${input.query}". Try a shorter, more distinctive term.`
|
|
66
|
+
: res.results.map((r) => {
|
|
67
|
+
const perms = r.requiredPermissions.length > 0 ? ` [${r.requiredPermissions.join(', ')}]` : '';
|
|
68
|
+
const body = r.bodyProps.length > 0 ? ` body: ${r.bodyProps.join(', ')}` : '';
|
|
69
|
+
const query = r.queryParams.length > 0 ? ` query: ${r.queryParams.join(', ')}` : '';
|
|
70
|
+
return `- ${r.method} ${r.path}${perms} - ${r.summary ?? ''}${query}${body}`;
|
|
71
|
+
}).join('\n');
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: 'text', text }],
|
|
74
|
+
structuredContent: res,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1518 - unit tests for `orboto_api_search`.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeApiSearchHandler } from './api-search.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
13
|
+
const r = responses.shift();
|
|
14
|
+
if (!r)
|
|
15
|
+
throw new Error('unexpected extra fetch');
|
|
16
|
+
return {
|
|
17
|
+
ok: r.ok ?? true,
|
|
18
|
+
status: r.status ?? 200,
|
|
19
|
+
statusText: 'OK',
|
|
20
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
21
|
+
text: async () => '',
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return calls;
|
|
25
|
+
}
|
|
26
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
27
|
+
const ENTRY = {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
path: '/projects/{projectId}/milestones',
|
|
30
|
+
summary: 'Create a milestone',
|
|
31
|
+
tags: ['Milestones'],
|
|
32
|
+
requiredPermissions: ['milestone:create'],
|
|
33
|
+
pathParams: ['projectId'],
|
|
34
|
+
queryParams: [],
|
|
35
|
+
bodyProps: ['name', 'startDate', 'endDate', 'isPrivate?'],
|
|
36
|
+
score: 12,
|
|
37
|
+
};
|
|
38
|
+
describe('orboto_api_search', () => {
|
|
39
|
+
it('search mode hits /system/api-catalog and renders matches with permissions', async () => {
|
|
40
|
+
const calls = stub([{ json: { query: 'create milestone', total: 1, results: [ENTRY] } }]);
|
|
41
|
+
const res = await makeApiSearchHandler(client)({ query: 'create milestone' });
|
|
42
|
+
expect(calls[0].url).toContain('/system/api-catalog?');
|
|
43
|
+
expect(calls[0].url).toContain('q=create+milestone');
|
|
44
|
+
expect(res.isError).toBeUndefined();
|
|
45
|
+
const text = res.content[0].text;
|
|
46
|
+
expect(text).toContain('POST /projects/{projectId}/milestones');
|
|
47
|
+
expect(text).toContain('milestone:create');
|
|
48
|
+
expect(res.structuredContent.total).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
it('detail mode hits /operation with path + method', async () => {
|
|
51
|
+
const calls = stub([{
|
|
52
|
+
json: {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
path: '/projects/{projectId}/milestones',
|
|
55
|
+
summary: 'Create a milestone',
|
|
56
|
+
description: null,
|
|
57
|
+
tags: ['Milestones'],
|
|
58
|
+
requiredPermissions: ['milestone:create'],
|
|
59
|
+
parameters: [{ name: 'projectId', in: 'path', required: true }],
|
|
60
|
+
requestBody: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
|
|
61
|
+
responses: { '200': { type: 'object' } },
|
|
62
|
+
},
|
|
63
|
+
}]);
|
|
64
|
+
const res = await makeApiSearchHandler(client)({
|
|
65
|
+
path: '/projects/{projectId}/milestones',
|
|
66
|
+
method: 'POST',
|
|
67
|
+
});
|
|
68
|
+
expect(calls[0].url).toContain('/system/api-catalog/operation?');
|
|
69
|
+
expect(calls[0].url).toContain('method=POST');
|
|
70
|
+
const sc = res.structuredContent;
|
|
71
|
+
expect(sc.requestBody.required).toContain('name');
|
|
72
|
+
expect(res.content[0].text).toContain('requires: milestone:create');
|
|
73
|
+
});
|
|
74
|
+
it('rejects detail mode without a method', async () => {
|
|
75
|
+
stub([]);
|
|
76
|
+
const res = await makeApiSearchHandler(client)({ path: '/projects' });
|
|
77
|
+
expect(res.isError).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('rejects a call with neither query nor path', async () => {
|
|
80
|
+
stub([]);
|
|
81
|
+
const res = await makeApiSearchHandler(client)({});
|
|
82
|
+
expect(res.isError).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it('renders an empty-result hint instead of erroring', async () => {
|
|
85
|
+
stub([{ json: { query: 'xyzzy', total: 0, results: [] } }]);
|
|
86
|
+
const res = await makeApiSearchHandler(client)({ query: 'xyzzy' });
|
|
87
|
+
expect(res.isError).toBeUndefined();
|
|
88
|
+
expect(res.content[0].text).toMatch(/no endpoints match/i);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1223 - approval / sign-off gate tools.
|
|
3
|
+
*
|
|
4
|
+
* - orboto_list_approvals - list a ticket's approval / sign-off requests.
|
|
5
|
+
* - orboto_approval_decide - cast an approve/reject vote on a pending request.
|
|
6
|
+
*
|
|
7
|
+
* Policy configuration stays out of the MCP surface (admin config, like the
|
|
8
|
+
* other project-settings surfaces the agent tools deliberately don't expose).
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { resolveTicketByKey } from './shared.js';
|
|
12
|
+
function fmt(r) {
|
|
13
|
+
return ` ${r.id} → "${r.toStatusName}" [${r.status}] ${r.approveCount}/${r.requiredApprovals} approvals (${r.policyName})${r.canApprove ? ' - you can vote' : ''}`;
|
|
14
|
+
}
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// orboto_list_approvals
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const listApprovalsToolConfig = {
|
|
19
|
+
title: 'List a ticket\'s approval / sign-off requests',
|
|
20
|
+
description: 'List the approval / sign-off requests on a ticket (generic change-management gates on status transitions). Shows each request\'s target status, mode, approval progress, and whether YOU can vote on it right now. Use this before `orboto_approval_decide`.',
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
ticketKey: z.string().min(3).describe('Ticket key, e.g. ORB-42.'),
|
|
23
|
+
}).shape,
|
|
24
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
25
|
+
};
|
|
26
|
+
export function makeListApprovalsHandler(client) {
|
|
27
|
+
return async ({ ticketKey }) => {
|
|
28
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
29
|
+
const rows = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}/approval-requests`);
|
|
30
|
+
const text = rows.length === 0
|
|
31
|
+
? `No approval requests on [${ticket.ticketKey}].`
|
|
32
|
+
: `Approval requests on [${ticket.ticketKey}]:\n${rows.map(fmt).join('\n')}`;
|
|
33
|
+
return { content: [{ type: 'text', text }], structuredContent: { ticketKey: ticket.ticketKey, requests: rows } };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// orboto_approval_decide
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export const approvalDecideToolConfig = {
|
|
40
|
+
title: 'Approve or reject a ticket sign-off request',
|
|
41
|
+
description: 'Cast an approve/reject vote on a pending approval / sign-off request that gates a ticket status transition. You must be an eligible approver (a policy role, a named user, or a RACI role on the ticket) - otherwise the API returns 403. A single reject resolves the request; enough approvals unblock the transition (the requester then re-applies the status move). If the ticket has several pending requests, pass `requestId`.',
|
|
42
|
+
inputSchema: z.object({
|
|
43
|
+
ticketKey: z.string().min(3).describe('Ticket key, e.g. ORB-42.'),
|
|
44
|
+
decision: z.enum(['approve', 'reject']).describe('Your decision.'),
|
|
45
|
+
comment: z.string().optional().describe('Optional decision comment (recorded on the vote + notified to the requester).'),
|
|
46
|
+
requestId: z.string().uuid().optional().describe('Disambiguate when the ticket has more than one pending request.'),
|
|
47
|
+
}).shape,
|
|
48
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
49
|
+
};
|
|
50
|
+
export function makeApprovalDecideHandler(client) {
|
|
51
|
+
return async ({ ticketKey, decision, comment, requestId }) => {
|
|
52
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
53
|
+
const rows = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}/approval-requests`);
|
|
54
|
+
const pending = rows.filter((r) => r.status === 'pending');
|
|
55
|
+
const target = requestId ? pending.find((r) => r.id === requestId) : pending.find((r) => r.canApprove) ?? pending[0];
|
|
56
|
+
if (!target) {
|
|
57
|
+
throw new Error(`No pending approval request to decide on [${ticket.ticketKey}]${requestId ? ` (requestId ${requestId} not pending)` : ''}.`);
|
|
58
|
+
}
|
|
59
|
+
const updated = await client.post(`/projects/${ticket.projectId}/approval-requests/${target.id}/decision`, { decision, comment: comment ?? null });
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: `Recorded ${decision} on request for "${updated.toStatusName}" - now ${updated.status} (${updated.approveCount}/${updated.requiredApprovals}).`,
|
|
64
|
+
}],
|
|
65
|
+
structuredContent: { ticketKey: ticket.ticketKey, request: updated },
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1223 - `orboto_list_approvals` / `orboto_approval_decide` unit tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeListApprovalsHandler, makeApprovalDecideHandler } from './approvals.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
|
|
13
|
+
const r = responses.shift();
|
|
14
|
+
if (!r)
|
|
15
|
+
throw new Error(`unexpected extra fetch ${init?.method ?? 'GET'} ${url}`);
|
|
16
|
+
return { ok: r.ok ?? true, status: r.status ?? 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
|
|
17
|
+
});
|
|
18
|
+
return calls;
|
|
19
|
+
}
|
|
20
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
21
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
22
|
+
const TICKET = { id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1, title: 'Bug', description: null, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress', type: 'bug', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false };
|
|
23
|
+
const REQ = { id: 'r1', toStatusName: 'In Review', policyName: 'Sign-off', mode: 'any_n', status: 'pending', requiredApprovals: 1, currentStep: 0, approveCount: 0, rejectCount: 0, requestedBy: 'u9', canApprove: true, votes: [] };
|
|
24
|
+
describe('orboto_list_approvals', () => {
|
|
25
|
+
it('lists a ticket\'s approval requests', async () => {
|
|
26
|
+
stub([{ json: PROJ }, { json: TICKET }, { json: [REQ] }]);
|
|
27
|
+
const res = await makeListApprovalsHandler(client)({ ticketKey: 'ACME-1' });
|
|
28
|
+
expect(res.structuredContent?.requests).toHaveLength(1);
|
|
29
|
+
expect(res.content[0].text).toContain('In Review');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('orboto_approval_decide', () => {
|
|
33
|
+
it('approves the pending request the caller can vote on', async () => {
|
|
34
|
+
const calls = stub([
|
|
35
|
+
{ json: PROJ }, { json: TICKET }, { json: [REQ] },
|
|
36
|
+
{ json: { ...REQ, status: 'approved', approveCount: 1 } },
|
|
37
|
+
]);
|
|
38
|
+
const res = await makeApprovalDecideHandler(client)({ ticketKey: 'ACME-1', decision: 'approve' });
|
|
39
|
+
const decide = calls.find((c) => c.method === 'POST');
|
|
40
|
+
expect(decide?.url).toContain('/approval-requests/r1/decision');
|
|
41
|
+
expect(decide?.body).toMatchObject({ decision: 'approve' });
|
|
42
|
+
expect(res.content[0].text).toContain('approved');
|
|
43
|
+
});
|
|
44
|
+
it('errors when no pending request exists', async () => {
|
|
45
|
+
stub([{ json: PROJ }, { json: TICKET }, { json: [{ ...REQ, status: 'consumed' }] }]);
|
|
46
|
+
await expect(makeApprovalDecideHandler(client)({ ticketKey: 'ACME-1', decision: 'approve' })).rejects.toThrow(/No pending approval/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - attach files to a ticket.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `orboto.mjs attach <ticket> <path...> [--alt "text"] [--embed]`.
|
|
5
|
+
*
|
|
6
|
+
* MCP-side, the model doesn't have local FS access, so the bytes come
|
|
7
|
+
* over the wire as a base64 `contentBase64` field - same approach as
|
|
8
|
+
* `orboto_ingest_file`. To keep the surface simple, this tool uploads
|
|
9
|
+
* one file per call; the wrapper's multi-file shorthand is unrolled
|
|
10
|
+
* into N tool calls on the agent side.
|
|
11
|
+
*
|
|
12
|
+
* Two modes, controlled by `embed`:
|
|
13
|
+
*
|
|
14
|
+
* - embed=false (default): upload only, return the markdown image
|
|
15
|
+
* line + raw URL. The agent typically uses this to compose a
|
|
16
|
+
* follow-up `orboto_comment` body.
|
|
17
|
+
* - embed=true: upload, then PATCH the ticket's description to
|
|
18
|
+
* append the markdown image at the end. Idempotent only up to
|
|
19
|
+
* duplicate detection (re-running with the same file *does*
|
|
20
|
+
* append a fresh line; the API doesn't dedupe).
|
|
21
|
+
*/
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { resolveTicketByKey } from './shared.js';
|
|
24
|
+
function mimetypeFor(filename) {
|
|
25
|
+
const lower = filename.toLowerCase();
|
|
26
|
+
if (lower.endsWith('.pdf'))
|
|
27
|
+
return 'application/pdf';
|
|
28
|
+
if (lower.endsWith('.png'))
|
|
29
|
+
return 'image/png';
|
|
30
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
|
|
31
|
+
return 'image/jpeg';
|
|
32
|
+
if (lower.endsWith('.gif'))
|
|
33
|
+
return 'image/gif';
|
|
34
|
+
if (lower.endsWith('.webp'))
|
|
35
|
+
return 'image/webp';
|
|
36
|
+
if (lower.endsWith('.svg'))
|
|
37
|
+
return 'image/svg+xml';
|
|
38
|
+
if (lower.endsWith('.txt') || lower.endsWith('.log'))
|
|
39
|
+
return 'text/plain';
|
|
40
|
+
if (lower.endsWith('.json'))
|
|
41
|
+
return 'application/json';
|
|
42
|
+
return 'application/octet-stream';
|
|
43
|
+
}
|
|
44
|
+
export const attachToTicketToolConfig = {
|
|
45
|
+
title: 'Attach a file to a ticket',
|
|
46
|
+
description: 'Upload a file as an attachment on a ticket and return its markdown image-embed line + raw URL. The bytes come in as base64 (`contentBase64`) so this works without local FS access on the agent side. Set `embed=true` to also PATCH the ticket\'s description with the markdown line appended (idempotent only up to duplicate detection - re-running with the same file appends another copy). Use `altText` for accessibility / image alt-text; defaults to the filename. Mirrors `orboto.mjs attach`.',
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
ticketKey: z.string().min(3),
|
|
49
|
+
filename: z.string().min(1).describe('Display filename, e.g. "graph-q3.png".'),
|
|
50
|
+
contentBase64: z.string().min(1).describe('File content, base64-encoded.'),
|
|
51
|
+
altText: z.string().optional().describe('Alt-text for the markdown image line. Defaults to filename.'),
|
|
52
|
+
embed: z.boolean().optional().describe('If true, also PATCH the ticket\'s description to append the markdown image line.'),
|
|
53
|
+
}).shape,
|
|
54
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
55
|
+
};
|
|
56
|
+
export function makeAttachToTicketHandler(client) {
|
|
57
|
+
return async ({ ticketKey, filename, contentBase64, altText, embed }) => {
|
|
58
|
+
let arrayBuffer;
|
|
59
|
+
try {
|
|
60
|
+
// Buffer → fresh ArrayBuffer copy. The Blob ctor's BlobPart type
|
|
61
|
+
// wants `ArrayBufferView<ArrayBuffer>` and Node @types reject a
|
|
62
|
+
// raw Buffer / Uint8Array because their backing buffer is the
|
|
63
|
+
// wider `ArrayBufferLike`. Copying the bytes into a fresh
|
|
64
|
+
// ArrayBuffer satisfies the constraint without losing data.
|
|
65
|
+
const buf = Buffer.from(contentBase64, 'base64');
|
|
66
|
+
arrayBuffer = new ArrayBuffer(buf.byteLength);
|
|
67
|
+
new Uint8Array(arrayBuffer).set(buf);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error('contentBase64 is not valid base64.');
|
|
71
|
+
}
|
|
72
|
+
if (arrayBuffer.byteLength === 0) {
|
|
73
|
+
throw new Error('contentBase64 decoded to 0 bytes - refuse to upload an empty file.');
|
|
74
|
+
}
|
|
75
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
76
|
+
const form = new FormData();
|
|
77
|
+
form.append('file', new Blob([arrayBuffer], { type: mimetypeFor(filename) }), filename);
|
|
78
|
+
const att = await client.postMultipart(`/tickets/${ticket.id}/attachments`, form);
|
|
79
|
+
const alt = altText ?? att.filename;
|
|
80
|
+
const markdown = ``;
|
|
81
|
+
let embedded = false;
|
|
82
|
+
if (embed === true) {
|
|
83
|
+
// Read the current description first - the PATCH replaces, not
|
|
84
|
+
// appends, so we have to construct the new body.
|
|
85
|
+
const current = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}`);
|
|
86
|
+
const existing = current.description ?? '';
|
|
87
|
+
const next = existing ? `${existing}\n\n${markdown}` : markdown;
|
|
88
|
+
await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { description: next });
|
|
89
|
+
embedded = true;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
content: [{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: `Attached ${att.filename} (${Math.round(att.sizeBytes / 1024)} KB) to [${ticket.ticketKey}]${embedded ? ' and embedded in description' : ''}.\n${markdown}`,
|
|
95
|
+
}],
|
|
96
|
+
structuredContent: {
|
|
97
|
+
ticketKey: ticket.ticketKey,
|
|
98
|
+
attachmentId: att.id,
|
|
99
|
+
filename: att.filename,
|
|
100
|
+
sizeBytes: att.sizeBytes,
|
|
101
|
+
mimetype: att.mimetype,
|
|
102
|
+
url: att.downloadUrl,
|
|
103
|
+
markdown,
|
|
104
|
+
embedded,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}
|