@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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-916 - doc revision history MCP tools (epic ORB-911 Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* - orboto_list_doc_revisions - GET /docs/:id/revisions (cursor-paged)
|
|
5
|
+
* - orboto_get_doc_revision - GET /docs/:id/revisions/:rid
|
|
6
|
+
* - orboto_restore_doc_revision - POST /docs/:id/revisions/:rid/restore
|
|
7
|
+
*
|
|
8
|
+
* Revisions are auto-captured by PATCH /docs/:id every time the title
|
|
9
|
+
* or body changes, so by the time an agent wants to roll back there's
|
|
10
|
+
* usually a long history to walk. The list endpoint is cursor-paged
|
|
11
|
+
* (newest-first by editedAt DESC + id tiebreak) - the tool surfaces
|
|
12
|
+
* `nextCursor` in structuredContent so the caller can page through if
|
|
13
|
+
* the default 25-row page isn't enough.
|
|
14
|
+
*
|
|
15
|
+
* Restore writes a new revision capturing the current body before
|
|
16
|
+
* rolling back, so the restore itself is also undoable.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { resolveDocId } from './docs.js';
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// orboto_list_doc_revisions
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export const listDocRevisionsToolConfig = {
|
|
24
|
+
title: 'List the revision history of a doc page',
|
|
25
|
+
description: 'Return saved revisions for a doc page, newest-first. Each row carries the revision id, title at snapshot time, editor user id, and editedAt timestamp; the body content is NOT in this list (use orboto_get_doc_revision to fetch one). Cursor-paged - pass `cursor` to walk older pages.',
|
|
26
|
+
inputSchema: z.object({
|
|
27
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
28
|
+
limit: z.number().int().min(1).max(100).optional().describe('Page size. Defaults to API default (25).'),
|
|
29
|
+
cursor: z.string().optional().describe('Opaque cursor from a previous call\'s nextCursor.'),
|
|
30
|
+
}).shape,
|
|
31
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
32
|
+
};
|
|
33
|
+
export function makeListDocRevisionsHandler(client) {
|
|
34
|
+
return async ({ docId, limit, cursor }) => {
|
|
35
|
+
docId = await resolveDocId(client, docId);
|
|
36
|
+
const qs = new URLSearchParams();
|
|
37
|
+
if (limit !== undefined)
|
|
38
|
+
qs.set('limit', String(limit));
|
|
39
|
+
if (cursor)
|
|
40
|
+
qs.set('cursor', cursor);
|
|
41
|
+
const query = qs.toString();
|
|
42
|
+
const path = `/docs/${docId}/revisions${query ? `?${query}` : ''}`;
|
|
43
|
+
const page = await client.get(path);
|
|
44
|
+
if (page.items.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: 'No revisions on this doc yet.' }],
|
|
47
|
+
structuredContent: { revisions: [], nextCursor: null },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const lines = page.items.map((r) => {
|
|
51
|
+
const editor = r.editedBy ?? '(unknown)';
|
|
52
|
+
return `- ${r.editedAt} · ${r.title} · by ${editor} · id: ${r.id}`;
|
|
53
|
+
});
|
|
54
|
+
if (page.nextCursor) {
|
|
55
|
+
lines.push('', `(more available - pass cursor: ${page.nextCursor})`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
59
|
+
structuredContent: {
|
|
60
|
+
revisions: page.items.map((r) => ({
|
|
61
|
+
id: r.id,
|
|
62
|
+
docId: r.docId,
|
|
63
|
+
title: r.title,
|
|
64
|
+
editedBy: r.editedBy,
|
|
65
|
+
editedAt: r.editedAt,
|
|
66
|
+
})),
|
|
67
|
+
nextCursor: page.nextCursor,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// orboto_get_doc_revision
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
export const getDocRevisionToolConfig = {
|
|
76
|
+
title: 'Get a single doc revision',
|
|
77
|
+
description: 'Return the full content + title of one saved revision. Use this to diff against the current doc before deciding whether to restore.',
|
|
78
|
+
inputSchema: z.object({
|
|
79
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
80
|
+
revisionId: z.string().uuid(),
|
|
81
|
+
}).shape,
|
|
82
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
83
|
+
};
|
|
84
|
+
export function makeGetDocRevisionHandler(client) {
|
|
85
|
+
return async ({ docId, revisionId }) => {
|
|
86
|
+
docId = await resolveDocId(client, docId);
|
|
87
|
+
const rev = await client.get(`/docs/${docId}/revisions/${revisionId}`);
|
|
88
|
+
return {
|
|
89
|
+
content: [{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: `# ${rev.title}\nRevision ${rev.id} · editedAt: ${rev.editedAt}\n\n${rev.content || '_(empty)_'}`,
|
|
92
|
+
}],
|
|
93
|
+
structuredContent: {
|
|
94
|
+
id: rev.id,
|
|
95
|
+
docId: rev.docId,
|
|
96
|
+
title: rev.title,
|
|
97
|
+
content: rev.content,
|
|
98
|
+
editedBy: rev.editedBy,
|
|
99
|
+
editedAt: rev.editedAt,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// orboto_restore_doc_revision
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
export const restoreDocRevisionToolConfig = {
|
|
108
|
+
title: 'Restore a doc page to a saved revision',
|
|
109
|
+
description: 'Roll back the doc to the saved revision. The API snapshots the CURRENT body to a fresh revision row first, so the restore itself is also undoable. Returns the post-restore doc state.',
|
|
110
|
+
inputSchema: z.object({
|
|
111
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
112
|
+
revisionId: z.string().uuid(),
|
|
113
|
+
}).shape,
|
|
114
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
115
|
+
};
|
|
116
|
+
export function makeRestoreDocRevisionHandler(client) {
|
|
117
|
+
return async ({ docId, revisionId }) => {
|
|
118
|
+
docId = await resolveDocId(client, docId);
|
|
119
|
+
const doc = await client.post(`/docs/${docId}/revisions/${revisionId}/restore`, {});
|
|
120
|
+
return {
|
|
121
|
+
content: [{
|
|
122
|
+
type: 'text',
|
|
123
|
+
text: `Restored doc ${doc.id} to revision ${revisionId}.\n title: ${doc.title}\n updatedAt: ${doc.updatedAt}`,
|
|
124
|
+
}],
|
|
125
|
+
structuredContent: {
|
|
126
|
+
docId: doc.id,
|
|
127
|
+
restoredFromRevisionId: revisionId,
|
|
128
|
+
title: doc.title,
|
|
129
|
+
updatedAt: doc.updatedAt,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-916 - doc-revisions tool tests.
|
|
3
|
+
*
|
|
4
|
+
* - list - happy + empty + nextCursor follow-up
|
|
5
|
+
* - get - happy
|
|
6
|
+
* - restore - happy + 404 bubble-up
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
10
|
+
import { makeListDocRevisionsHandler, makeGetDocRevisionHandler, makeRestoreDocRevisionHandler, } from './doc-revisions.js';
|
|
11
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
12
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
13
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
14
|
+
const DOC_ID = 'd0000000-0000-0000-0000-000000000001';
|
|
15
|
+
const REV_ID = 'a0000000-0000-0000-0000-000000000111';
|
|
16
|
+
function stubJSON(responses) {
|
|
17
|
+
const calls = [];
|
|
18
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
19
|
+
calls.push({
|
|
20
|
+
url: url.toString(),
|
|
21
|
+
method: init?.method ?? 'GET',
|
|
22
|
+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
|
|
23
|
+
});
|
|
24
|
+
const r = responses.shift();
|
|
25
|
+
if (!r)
|
|
26
|
+
throw new Error('unexpected extra fetch');
|
|
27
|
+
return {
|
|
28
|
+
ok: r.ok ?? true,
|
|
29
|
+
status: r.status ?? 200,
|
|
30
|
+
statusText: 'OK',
|
|
31
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
32
|
+
text: async () => '',
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return calls;
|
|
36
|
+
}
|
|
37
|
+
const REVISION_LIST_ROW = {
|
|
38
|
+
id: REV_ID,
|
|
39
|
+
docId: DOC_ID,
|
|
40
|
+
title: 'Previous title',
|
|
41
|
+
editedBy: 'u1',
|
|
42
|
+
editedAt: '2026-05-17T12:00:00.000Z',
|
|
43
|
+
};
|
|
44
|
+
describe('orboto_list_doc_revisions', () => {
|
|
45
|
+
it('GETs /docs/:id/revisions with cursor + limit when supplied', async () => {
|
|
46
|
+
const calls = stubJSON([{ json: { items: [REVISION_LIST_ROW], nextCursor: null } }]);
|
|
47
|
+
await makeListDocRevisionsHandler(client)({
|
|
48
|
+
docId: DOC_ID,
|
|
49
|
+
limit: 50,
|
|
50
|
+
cursor: 'eyJ0IjoiZXhhbXBsZSJ9',
|
|
51
|
+
});
|
|
52
|
+
expect(calls[0].url).toContain(`/docs/${DOC_ID}/revisions`);
|
|
53
|
+
expect(calls[0].url).toContain('limit=50');
|
|
54
|
+
expect(calls[0].url).toContain('cursor=eyJ0IjoiZXhhbXBsZSJ9');
|
|
55
|
+
});
|
|
56
|
+
it('surfaces nextCursor in the structuredContent + the text body', async () => {
|
|
57
|
+
stubJSON([{ json: { items: [REVISION_LIST_ROW], nextCursor: 'NEXT' } }]);
|
|
58
|
+
const res = await makeListDocRevisionsHandler(client)({ docId: DOC_ID });
|
|
59
|
+
expect(res.content[0].text).toContain('NEXT');
|
|
60
|
+
expect(res.structuredContent).toMatchObject({ nextCursor: 'NEXT' });
|
|
61
|
+
});
|
|
62
|
+
it('reports the empty-history case explicitly', async () => {
|
|
63
|
+
stubJSON([{ json: { items: [], nextCursor: null } }]);
|
|
64
|
+
const res = await makeListDocRevisionsHandler(client)({ docId: DOC_ID });
|
|
65
|
+
expect(res.content[0].text).toContain('No revisions');
|
|
66
|
+
expect(res.structuredContent).toMatchObject({ revisions: [], nextCursor: null });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('orboto_get_doc_revision', () => {
|
|
70
|
+
it('GETs the full revision row including content', async () => {
|
|
71
|
+
stubJSON([{ json: { ...REVISION_LIST_ROW, content: '# Previous title\n\nOld body.' } }]);
|
|
72
|
+
const res = await makeGetDocRevisionHandler(client)({ docId: DOC_ID, revisionId: REV_ID });
|
|
73
|
+
expect(res.structuredContent).toMatchObject({
|
|
74
|
+
id: REV_ID,
|
|
75
|
+
content: '# Previous title\n\nOld body.',
|
|
76
|
+
});
|
|
77
|
+
expect(res.content[0].text).toContain('Old body.');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('orboto_restore_doc_revision', () => {
|
|
81
|
+
it('POSTs to /docs/:id/revisions/:rid/restore and surfaces the restored doc', async () => {
|
|
82
|
+
const calls = stubJSON([{
|
|
83
|
+
json: {
|
|
84
|
+
id: DOC_ID, spaceId: 's1', parentDocId: null, title: 'Previous title',
|
|
85
|
+
content: '# Previous title', slug: 'previous', visibility: 'workspace',
|
|
86
|
+
icon: null, sortOrder: 0, updatedAt: '2026-05-17T13:30:00.000Z',
|
|
87
|
+
},
|
|
88
|
+
}]);
|
|
89
|
+
const res = await makeRestoreDocRevisionHandler(client)({ docId: DOC_ID, revisionId: REV_ID });
|
|
90
|
+
expect(calls[0]).toMatchObject({
|
|
91
|
+
method: 'POST',
|
|
92
|
+
url: `https://orboto.example.com/docs/${DOC_ID}/revisions/${REV_ID}/restore`,
|
|
93
|
+
});
|
|
94
|
+
expect(res.structuredContent).toMatchObject({
|
|
95
|
+
docId: DOC_ID,
|
|
96
|
+
restoredFromRevisionId: REV_ID,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
it('bubbles up a 404 from an unknown revision id', async () => {
|
|
100
|
+
stubJSON([{ ok: false, status: 404, json: { error: 'Not found' } }]);
|
|
101
|
+
await expect(makeRestoreDocRevisionHandler(client)({ docId: DOC_ID, revisionId: 'b0000000-0000-0000-0000-000000000999' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - docs-AI surface (ask + ingest).
|
|
3
|
+
*
|
|
4
|
+
* Three tools that mirror the wrapper's `ask-docs` / `ingest-url` /
|
|
5
|
+
* `ingest-file` subcommands. Together they're the foundation for any
|
|
6
|
+
* agent that needs to "remember this URL" / "add this PDF to our
|
|
7
|
+
* wiki" workflows.
|
|
8
|
+
*
|
|
9
|
+
* - orboto_ask_docs - RAG Q&A across wiki docs, returns answer +
|
|
10
|
+
* citations. Requires both `configured` and
|
|
11
|
+
* `embeddingsConfigured` on `/ai/status`
|
|
12
|
+
* (RAG retrieval needs an embedding-capable
|
|
13
|
+
* provider).
|
|
14
|
+
* - orboto_ingest_url - fetch + extract + create doc from a public
|
|
15
|
+
* URL via the Readability-fallback pipeline.
|
|
16
|
+
* - orboto_ingest_file - upload + extract + create doc from a local
|
|
17
|
+
* file (PDF / DOCX / Markdown / plain text).
|
|
18
|
+
* Uses the multipart-upload route, so the MCP
|
|
19
|
+
* tool receives the bytes as a base64 string
|
|
20
|
+
* to avoid the model needing local FS access.
|
|
21
|
+
*
|
|
22
|
+
* AI-gated note: the `ai_status` tool exists for pre-flight. We do NOT
|
|
23
|
+
* pre-check inside the handlers - the API returns the same gating
|
|
24
|
+
* error regardless and a pre-check would double the latency of every
|
|
25
|
+
* call. Models are expected to call `orboto_ai_status` once per
|
|
26
|
+
* session if they're unsure of workspace shape.
|
|
27
|
+
*/
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// orboto_ask_docs
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export const askDocsToolConfig = {
|
|
33
|
+
title: 'Ask a question against the wiki (RAG)',
|
|
34
|
+
description: 'Run a natural-language question against the wiki via the workspace\'s RAG pipeline. Returns an `answer` (Markdown) + `citations` array `[{index, title, link, spaceName}]`. The model is the workspace\'s configured AI provider; retrieval requires the embedding provider too - call `orboto_ai_status` to verify both before relying on this. Restrict to a single doc space with `spaceId` when scoping a query (e.g. "only the runbooks space"). Latency is dominated by retrieval + LLM round-trip; expect 2-10s.',
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
question: z.string().min(3),
|
|
37
|
+
spaceId: z.string().min(1).optional().describe('Limit RAG retrieval to one doc space - key (e.g. ORB-S1), name, or UUID.'),
|
|
38
|
+
limit: z.number().int().min(1).max(20).optional().describe('Max chunks to retrieve (default: 5).'),
|
|
39
|
+
}).shape,
|
|
40
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
41
|
+
};
|
|
42
|
+
export function makeAskDocsHandler(client) {
|
|
43
|
+
return async ({ question, spaceId, limit }) => {
|
|
44
|
+
const body = { question, limit: limit ?? 5 };
|
|
45
|
+
if (spaceId)
|
|
46
|
+
body.spaceId = spaceId;
|
|
47
|
+
const res = await client.post('/ai/ask-docs', body);
|
|
48
|
+
const lines = [res.answer.trim()];
|
|
49
|
+
if (res.citations.length > 0) {
|
|
50
|
+
lines.push('', 'Sources:');
|
|
51
|
+
for (const c of res.citations) {
|
|
52
|
+
const spaceTag = c.spaceName ? `[${c.spaceName}] ` : '';
|
|
53
|
+
lines.push(` [${c.index}] ${spaceTag}${c.title} ${c.link}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
lines.push('', `(retrieval mode: ${res.mode})`);
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
59
|
+
structuredContent: {
|
|
60
|
+
answer: res.answer,
|
|
61
|
+
citations: res.citations,
|
|
62
|
+
mode: res.mode,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// orboto_ingest_url
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
export const ingestUrlToolConfig = {
|
|
71
|
+
title: 'Ingest a public URL into a wiki space',
|
|
72
|
+
description: 'Fetch a public URL, run Readability extraction, store the result as a Markdown doc in `spaceId`. Optionally nest under `parentDocId` to keep the tree tidy. Sets `readabilityFallback: true` when the page didn\'t look like a recognisable article (the body falls back to full-page text - a flag the caller should surface to the operator for review).',
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
url: z.string().url(),
|
|
75
|
+
spaceId: z.string().uuid().describe('Target doc space - find IDs via `orboto_list_doc_spaces`.'),
|
|
76
|
+
parentDocId: z.string().uuid().optional().describe('Optional parent doc to nest under.'),
|
|
77
|
+
}).shape,
|
|
78
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
79
|
+
};
|
|
80
|
+
export function makeIngestUrlHandler(client) {
|
|
81
|
+
return async ({ url, spaceId, parentDocId }) => {
|
|
82
|
+
const body = { url };
|
|
83
|
+
if (parentDocId)
|
|
84
|
+
body.parentDocId = parentDocId;
|
|
85
|
+
const res = await client.post(`/spaces/${spaceId}/docs/ingest-url`, body);
|
|
86
|
+
const lines = [
|
|
87
|
+
`Created doc: ${res.title}`,
|
|
88
|
+
` id: ${res.docId}`,
|
|
89
|
+
` slug: ${res.slug}`,
|
|
90
|
+
` fetched ${Math.round(res.fetchedBytes / 1024)} KB → ${res.markdownChars} chars of markdown`,
|
|
91
|
+
];
|
|
92
|
+
if (res.readabilityFallback) {
|
|
93
|
+
lines.push(' ! Readability couldn\'t identify a main article - body is full-page text. Review before relying.');
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
97
|
+
structuredContent: {
|
|
98
|
+
docId: res.docId,
|
|
99
|
+
title: res.title,
|
|
100
|
+
slug: res.slug,
|
|
101
|
+
fetchedBytes: res.fetchedBytes,
|
|
102
|
+
markdownChars: res.markdownChars,
|
|
103
|
+
readabilityFallback: res.readabilityFallback === true,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// orboto_ingest_file
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function mimetypeFor(filename) {
|
|
112
|
+
const lower = filename.toLowerCase();
|
|
113
|
+
if (lower.endsWith('.pdf'))
|
|
114
|
+
return 'application/pdf';
|
|
115
|
+
if (lower.endsWith('.docx'))
|
|
116
|
+
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
117
|
+
if (lower.endsWith('.md') || lower.endsWith('.markdown'))
|
|
118
|
+
return 'text/markdown';
|
|
119
|
+
return 'text/plain';
|
|
120
|
+
}
|
|
121
|
+
export const ingestFileToolConfig = {
|
|
122
|
+
title: 'Ingest a local file into a wiki space (multipart upload)',
|
|
123
|
+
description: 'Upload a file (PDF / DOCX / Markdown / plain text) into `spaceId` via multipart. The model passes the bytes as a base64 `contentBase64` field (so this works without local FS access on the agent side). Use `filename` to give the doc a meaningful title - the backend sniffs the mimetype but also uses the filename for display. Optional `parentDocId` nests under an existing doc.',
|
|
124
|
+
inputSchema: z.object({
|
|
125
|
+
spaceId: z.string().uuid(),
|
|
126
|
+
filename: z.string().min(1).describe('Display filename, e.g. "ADR-12-secrets-rotation.pdf".'),
|
|
127
|
+
contentBase64: z.string().min(1).describe('File content, base64-encoded.'),
|
|
128
|
+
parentDocId: z.string().uuid().optional(),
|
|
129
|
+
}).shape,
|
|
130
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
131
|
+
};
|
|
132
|
+
export function makeIngestFileHandler(client) {
|
|
133
|
+
return async ({ spaceId, filename, contentBase64, parentDocId }) => {
|
|
134
|
+
let arrayBuffer;
|
|
135
|
+
try {
|
|
136
|
+
// See attach.ts for the Buffer → ArrayBuffer copy rationale.
|
|
137
|
+
const buf = Buffer.from(contentBase64, 'base64');
|
|
138
|
+
arrayBuffer = new ArrayBuffer(buf.byteLength);
|
|
139
|
+
new Uint8Array(arrayBuffer).set(buf);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error('contentBase64 is not valid base64.');
|
|
143
|
+
}
|
|
144
|
+
if (arrayBuffer.byteLength === 0) {
|
|
145
|
+
throw new Error('contentBase64 decoded to 0 bytes - refuse to upload an empty file.');
|
|
146
|
+
}
|
|
147
|
+
const form = new FormData();
|
|
148
|
+
form.append('file', new Blob([arrayBuffer], { type: mimetypeFor(filename) }), filename);
|
|
149
|
+
if (parentDocId)
|
|
150
|
+
form.append('parentDocId', parentDocId);
|
|
151
|
+
const res = await client.postMultipart(`/spaces/${spaceId}/docs/ingest-file`, form);
|
|
152
|
+
const lines = [
|
|
153
|
+
`Created doc: ${res.title}`,
|
|
154
|
+
` id: ${res.docId}`,
|
|
155
|
+
` slug: ${res.slug}`,
|
|
156
|
+
` format: ${res.kind}`,
|
|
157
|
+
` uploaded ${Math.round(res.sizeBytes / 1024)} KB → ${res.markdownChars} chars of markdown`,
|
|
158
|
+
];
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
161
|
+
structuredContent: {
|
|
162
|
+
docId: res.docId,
|
|
163
|
+
title: res.title,
|
|
164
|
+
slug: res.slug,
|
|
165
|
+
kind: res.kind,
|
|
166
|
+
sizeBytes: res.sizeBytes,
|
|
167
|
+
markdownChars: res.markdownChars,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - docs-AI tools tests.
|
|
3
|
+
*
|
|
4
|
+
* `orboto_ask_docs` happy + AI-not-configured-style 400.
|
|
5
|
+
* `orboto_ingest_url` happy + readability-fallback flag.
|
|
6
|
+
* `orboto_ingest_file` happy + empty-bytes refusal.
|
|
7
|
+
*
|
|
8
|
+
* The multipart upload's wire format is verified by inspecting the
|
|
9
|
+
* FormData body the fetch mock receives - we read the form-fields
|
|
10
|
+
* back to assert filename + parent.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
14
|
+
import { makeAskDocsHandler, makeIngestUrlHandler, makeIngestFileHandler, } from './docs-ai.js';
|
|
15
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
16
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
17
|
+
function stubJSON(responses) {
|
|
18
|
+
const calls = [];
|
|
19
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
20
|
+
calls.push({
|
|
21
|
+
url: url.toString(),
|
|
22
|
+
method: init?.method ?? 'GET',
|
|
23
|
+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
|
|
24
|
+
});
|
|
25
|
+
const r = responses.shift();
|
|
26
|
+
if (!r)
|
|
27
|
+
throw new Error(`unexpected extra fetch`);
|
|
28
|
+
return {
|
|
29
|
+
ok: r.ok ?? true,
|
|
30
|
+
status: r.status ?? 200,
|
|
31
|
+
statusText: 'OK',
|
|
32
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
33
|
+
text: async () => '',
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return calls;
|
|
37
|
+
}
|
|
38
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
39
|
+
describe('orboto_ask_docs', () => {
|
|
40
|
+
it('POSTs question + limit + spaceId, returns answer + citations', async () => {
|
|
41
|
+
const calls = stubJSON([
|
|
42
|
+
{ json: {
|
|
43
|
+
answer: 'The retry backoff is 200ms × 2^n.',
|
|
44
|
+
citations: [
|
|
45
|
+
{ index: 1, title: 'Queue worker', link: '/docs/queue', spaceName: 'Runbooks' },
|
|
46
|
+
],
|
|
47
|
+
mode: 'rag',
|
|
48
|
+
} },
|
|
49
|
+
]);
|
|
50
|
+
const res = await makeAskDocsHandler(client)({
|
|
51
|
+
question: 'What is the queue retry backoff?',
|
|
52
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
53
|
+
limit: 3,
|
|
54
|
+
});
|
|
55
|
+
expect(calls[0]).toMatchObject({
|
|
56
|
+
method: 'POST',
|
|
57
|
+
url: 'https://orboto.example.com/ai/ask-docs',
|
|
58
|
+
body: {
|
|
59
|
+
question: 'What is the queue retry backoff?',
|
|
60
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
61
|
+
limit: 3,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(res.content[0].text).toContain('200ms');
|
|
65
|
+
expect(res.structuredContent).toMatchObject({
|
|
66
|
+
answer: 'The retry backoff is 200ms × 2^n.',
|
|
67
|
+
mode: 'rag',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('surfaces a 400 (AI not configured) as OrbotoApiError', async () => {
|
|
71
|
+
stubJSON([
|
|
72
|
+
{ ok: false, status: 400, json: { error: 'ai_provider_not_configured' } },
|
|
73
|
+
]);
|
|
74
|
+
await expect(makeAskDocsHandler(client)({ question: 'anything' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('orboto_ingest_url', () => {
|
|
78
|
+
it('POSTs url + parentDocId to the space ingest route', async () => {
|
|
79
|
+
const calls = stubJSON([
|
|
80
|
+
{ json: {
|
|
81
|
+
docId: 'd1', title: 'Hello', slug: 'hello',
|
|
82
|
+
fetchedBytes: 12345, markdownChars: 678, readabilityFallback: false,
|
|
83
|
+
} },
|
|
84
|
+
]);
|
|
85
|
+
const res = await makeIngestUrlHandler(client)({
|
|
86
|
+
url: 'https://example.com/article',
|
|
87
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
88
|
+
parentDocId: '99999999-8888-7777-6666-555555555555',
|
|
89
|
+
});
|
|
90
|
+
expect(calls[0]).toMatchObject({
|
|
91
|
+
method: 'POST',
|
|
92
|
+
url: 'https://orboto.example.com/spaces/11111111-2222-3333-4444-555555555555/docs/ingest-url',
|
|
93
|
+
body: {
|
|
94
|
+
url: 'https://example.com/article',
|
|
95
|
+
parentDocId: '99999999-8888-7777-6666-555555555555',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(res.structuredContent).toMatchObject({
|
|
99
|
+
docId: 'd1', title: 'Hello', readabilityFallback: false,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it('surfaces readabilityFallback=true in both text + structured', async () => {
|
|
103
|
+
stubJSON([
|
|
104
|
+
{ json: {
|
|
105
|
+
docId: 'd1', title: 'Page', slug: 'page',
|
|
106
|
+
fetchedBytes: 200, markdownChars: 50, readabilityFallback: true,
|
|
107
|
+
} },
|
|
108
|
+
]);
|
|
109
|
+
const res = await makeIngestUrlHandler(client)({
|
|
110
|
+
url: 'https://example.com/odd',
|
|
111
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
112
|
+
});
|
|
113
|
+
expect(res.content[0].text).toMatch(/Readability/);
|
|
114
|
+
expect(res.structuredContent).toMatchObject({ readabilityFallback: true });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('orboto_ingest_file', () => {
|
|
118
|
+
it('POSTs multipart with filename + decoded content', async () => {
|
|
119
|
+
// Capture the multipart body via the fetch mock - FormData is
|
|
120
|
+
// opaque to JSON.parse, so we don't decode it; we only assert the
|
|
121
|
+
// route, method, and the JSON response shape.
|
|
122
|
+
const calls = [];
|
|
123
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
124
|
+
calls.push({
|
|
125
|
+
url: url.toString(),
|
|
126
|
+
method: init?.method ?? 'GET',
|
|
127
|
+
bodyType: init?.body?.constructor?.name ?? 'undefined',
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
ok: true, status: 200, statusText: 'OK',
|
|
131
|
+
json: async () => ({
|
|
132
|
+
docId: 'd9', title: 'note.md', slug: 'note',
|
|
133
|
+
kind: 'markdown', sizeBytes: 11, markdownChars: 9,
|
|
134
|
+
}),
|
|
135
|
+
text: async () => '',
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
const base64 = Buffer.from('hello world').toString('base64');
|
|
139
|
+
const res = await makeIngestFileHandler(client)({
|
|
140
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
141
|
+
filename: 'note.md',
|
|
142
|
+
contentBase64: base64,
|
|
143
|
+
});
|
|
144
|
+
expect(calls[0]).toMatchObject({
|
|
145
|
+
method: 'POST',
|
|
146
|
+
url: 'https://orboto.example.com/spaces/11111111-2222-3333-4444-555555555555/docs/ingest-file',
|
|
147
|
+
bodyType: 'FormData',
|
|
148
|
+
});
|
|
149
|
+
expect(res.structuredContent).toMatchObject({
|
|
150
|
+
docId: 'd9', title: 'note.md', kind: 'markdown',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
it('refuses an empty base64 payload', async () => {
|
|
154
|
+
await expect(makeIngestFileHandler(client)({
|
|
155
|
+
spaceId: '11111111-2222-3333-4444-555555555555',
|
|
156
|
+
filename: 'empty.txt',
|
|
157
|
+
contentBase64: '',
|
|
158
|
+
})).rejects.toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|