@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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1342 (epic ORB-1339) - doc snippet-search + targeted-edit MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Phase-3 four-way-sync rollout of the two REST primitives shipped in
|
|
5
|
+
* ORB-1340 (GET /docs/search) and ORB-1341 (POST /docs/:id/edits). The
|
|
6
|
+
* whole point of the feature is context efficiency: an agent finds the
|
|
7
|
+
* right passage with one search (snippet + heading anchor, NOT the full
|
|
8
|
+
* doc body) and changes it with one edit that ships only the diff in both
|
|
9
|
+
* directions. Prefer these over orboto_get_doc + orboto_update_doc for
|
|
10
|
+
* small changes to a large doc.
|
|
11
|
+
*
|
|
12
|
+
* Three tools:
|
|
13
|
+
* - orboto_search_docs GET /docs/search
|
|
14
|
+
* - orboto_edit_doc POST /docs/:id/edits (string-replace edits)
|
|
15
|
+
* - orboto_edit_doc_section POST /docs/:id/edits (heading-addressed ops)
|
|
16
|
+
*
|
|
17
|
+
* The two edit tools hit the same endpoint with a different half of its
|
|
18
|
+
* body, so they share the 409-conflict translation below: a machine-
|
|
19
|
+
* readable conflict (no/ambiguous match, stale revision, heading not
|
|
20
|
+
* found / ambiguous) comes back as a NON-throwing tool result with
|
|
21
|
+
* `isError: true` so the model can self-correct instead of the raw API
|
|
22
|
+
* error bubbling up as an opaque failure.
|
|
23
|
+
*/
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
26
|
+
import { resolveDocId } from './docs.js';
|
|
27
|
+
import { resolveProjectByKey } from './shared.js';
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// 409 translation - shared by both edit tools.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** Turn a 409 from POST /docs/:id/edits into a clear, non-throwing tool
|
|
32
|
+
* result the model can act on. Returns null for any non-409 so the caller
|
|
33
|
+
* rethrows (a genuine transport/permission failure should still surface). */
|
|
34
|
+
function editConflictResult(err) {
|
|
35
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 409)
|
|
36
|
+
return null;
|
|
37
|
+
let c = {};
|
|
38
|
+
try {
|
|
39
|
+
c = JSON.parse(err.body);
|
|
40
|
+
}
|
|
41
|
+
catch { /* non-JSON body */ }
|
|
42
|
+
let text;
|
|
43
|
+
switch (c.reason) {
|
|
44
|
+
case 'no_match':
|
|
45
|
+
text =
|
|
46
|
+
`⛔ Edit ${c.editIndex} did not apply: oldString was not found in the doc (0 matches).\n` +
|
|
47
|
+
`The doc content may differ from what you expected. Re-read the passage (orboto_search_docs or orboto_get_doc) and rebuild the oldString to match exactly, whitespace included.`;
|
|
48
|
+
break;
|
|
49
|
+
case 'ambiguous_match':
|
|
50
|
+
text =
|
|
51
|
+
`⛔ Edit ${c.editIndex} did not apply: oldString matched ${c.occurrences ?? 'multiple'} times.\n` +
|
|
52
|
+
`Add surrounding context so the oldString is unique, or pass replaceAll=true to change every occurrence deliberately.`;
|
|
53
|
+
break;
|
|
54
|
+
case 'stale_revision':
|
|
55
|
+
text =
|
|
56
|
+
`⛔ Doc changed since your baseRevisionId; nothing was written.\n` +
|
|
57
|
+
`Re-read the doc, rebuild the edit against the current content, and retry with baseRevisionId="${c.currentRevisionId ?? 'null'}".`;
|
|
58
|
+
break;
|
|
59
|
+
case 'heading_not_found':
|
|
60
|
+
text =
|
|
61
|
+
`⛔ Section op ${c.editIndex}: no heading matched the path [${(c.headingPath ?? []).join(' > ')}].\n` +
|
|
62
|
+
`Heading matching is exact + case-sensitive. Check the exact heading text (orboto_search_docs returns each hit's headingPath).`;
|
|
63
|
+
break;
|
|
64
|
+
case 'ambiguous_heading':
|
|
65
|
+
text =
|
|
66
|
+
`⛔ Section op ${c.editIndex}: the heading path [${(c.headingPath ?? []).join(' > ')}] matched multiple headings.\n` +
|
|
67
|
+
(c.candidates?.length ? `Candidates: ${c.candidates.join(' | ')}.\n` : '') +
|
|
68
|
+
`Narrow the path by adding ancestor headings to disambiguate.`;
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
text = `⛔ Edit rejected (409): ${c.error ?? (err.body || 'conflict')}.`;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text }],
|
|
75
|
+
structuredContent: { conflict: true, ...c },
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function renderEditResult(res) {
|
|
80
|
+
const lines = [
|
|
81
|
+
`Applied ${res.edits.length} change(s) to doc ${res.docKey ?? res.docId}.`,
|
|
82
|
+
` new revisionId: ${res.revisionId ?? '(no content change)'}`,
|
|
83
|
+
'',
|
|
84
|
+
...res.edits.map((w) => ` [${w.index}] ${w.kind} · replaced ${w.replaced} · …${w.window}…`),
|
|
85
|
+
];
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
88
|
+
structuredContent: {
|
|
89
|
+
docId: res.docId,
|
|
90
|
+
docKey: res.docKey ?? null,
|
|
91
|
+
revisionId: res.revisionId,
|
|
92
|
+
edits: res.edits.map((w) => ({
|
|
93
|
+
index: w.index,
|
|
94
|
+
kind: w.kind,
|
|
95
|
+
replaced: w.replaced,
|
|
96
|
+
window: w.window,
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// orboto_search_docs (GET /docs/search - ORB-1340)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
export const searchDocsToolConfig = {
|
|
105
|
+
title: 'Search docs for snippets (context-efficient)',
|
|
106
|
+
description: 'Full-text search across wiki docs that returns a highlighted SNIPPET (matched fragments, <mark>…</mark> around each hit) plus the nearest markdown heading path (section anchor) and char/line offset per hit - WITHOUT echoing the full doc body. Use this instead of orboto_get_doc when you only need to locate a passage: find the right doc + section, then change it with orboto_edit_doc / orboto_edit_doc_section, all without transferring the whole document. ACL-filtered in SQL (you only see docs you can read). Ranked by relevance.',
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
q: z.string().min(1).max(10_000).describe('Search terms. Keyword FTS (tsvector); more distinctive words rank higher.'),
|
|
109
|
+
projectKey: z.string().min(1).optional().describe('Scope to one project by key (e.g. "ORB"). Resolved to the project UUID.'),
|
|
110
|
+
spaceId: z.string().uuid().optional().describe('Scope to one doc space by UUID (discover via orboto_list_doc_spaces).'),
|
|
111
|
+
limit: z.number().int().positive().max(100).default(20).describe('Max hits (default 20, max 100).'),
|
|
112
|
+
}).shape,
|
|
113
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
114
|
+
};
|
|
115
|
+
export function makeSearchDocsHandler(client) {
|
|
116
|
+
return async (input) => {
|
|
117
|
+
const qs = new URLSearchParams({ q: input.q });
|
|
118
|
+
if (input.spaceId)
|
|
119
|
+
qs.set('spaceId', input.spaceId);
|
|
120
|
+
if (input.projectKey) {
|
|
121
|
+
const project = await resolveProjectByKey(client, input.projectKey);
|
|
122
|
+
qs.set('projectId', project.id);
|
|
123
|
+
}
|
|
124
|
+
if (input.limit)
|
|
125
|
+
qs.set('limit', String(input.limit));
|
|
126
|
+
const res = await client.get(`/docs/search?${qs.toString()}`);
|
|
127
|
+
if (res.items.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: 'text', text: `No docs matched "${input.q}".` }],
|
|
130
|
+
structuredContent: { items: [], total: 0 },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const lines = res.items.map((h) => {
|
|
134
|
+
const anchor = h.headingPath.length ? ` §${h.headingPath.join(' > ')}` : '';
|
|
135
|
+
const where = h.charOffset >= 0 ? ` (line ${h.lineOffset})` : '';
|
|
136
|
+
return `- ${h.title} (${h.docKey ?? h.id})${anchor}${where}\n ${h.snippet.replace(/\s+/g, ' ').trim()}`;
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
140
|
+
structuredContent: {
|
|
141
|
+
items: res.items.map((h) => ({
|
|
142
|
+
id: h.id,
|
|
143
|
+
docKey: h.docKey,
|
|
144
|
+
title: h.title,
|
|
145
|
+
spaceId: h.spaceId,
|
|
146
|
+
spaceName: h.spaceName,
|
|
147
|
+
projectId: h.projectId,
|
|
148
|
+
snippet: h.snippet,
|
|
149
|
+
headingPath: h.headingPath,
|
|
150
|
+
charOffset: h.charOffset,
|
|
151
|
+
lineOffset: h.lineOffset,
|
|
152
|
+
rank: h.rank,
|
|
153
|
+
url: h.url,
|
|
154
|
+
})),
|
|
155
|
+
total: res.total,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// orboto_edit_doc (POST /docs/:id/edits - string-replace, ORB-1341)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
export const editDocToolConfig = {
|
|
164
|
+
title: 'Targeted string-replace edit of a doc (context-efficient)',
|
|
165
|
+
description: 'Change a doc by byte-precise string replacement (Edit-tool-like) - ship only the diff, not the whole doc. Each oldString must match EXACTLY ONCE in the current content (add surrounding context to make it unique) unless replaceAll=true (then >=1). The whole batch is atomic: any match failure applies nothing and returns a 409 you can act on. Prefer this over orboto_update_doc for small changes to a large doc. Pass baseRevisionId (from a previous edit\'s revisionId or orboto_list_doc_revisions) for optimistic concurrency - a stale token is rejected with the current revision id instead of clobbering a concurrent change. Returns a short context window around each change, not the full doc.',
|
|
166
|
+
inputSchema: z.object({
|
|
167
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable key (ORB-D12 / DOC-5). Find via orboto_search_docs.'),
|
|
168
|
+
edits: z.array(z.object({
|
|
169
|
+
oldString: z.string().min(1).describe('Exact substring to replace. Must be unique in the doc unless replaceAll.'),
|
|
170
|
+
newString: z.string().describe('Replacement text (may be empty to delete).'),
|
|
171
|
+
replaceAll: z.boolean().optional().describe('Replace every occurrence instead of requiring exactly one.'),
|
|
172
|
+
})).min(1).max(100).describe('Applied sequentially; edit N sees the result of edits 0..N-1.'),
|
|
173
|
+
baseRevisionId: z.string().uuid().optional().describe('Optimistic-concurrency token = the revision id you last saw. Omit to always apply.'),
|
|
174
|
+
}).shape,
|
|
175
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
176
|
+
};
|
|
177
|
+
export function makeEditDocHandler(client) {
|
|
178
|
+
return async (input) => {
|
|
179
|
+
const docId = await resolveDocId(client, input.docId);
|
|
180
|
+
const body = { edits: input.edits };
|
|
181
|
+
if (input.baseRevisionId)
|
|
182
|
+
body.baseRevisionId = input.baseRevisionId;
|
|
183
|
+
try {
|
|
184
|
+
const res = await client.post(`/docs/${docId}/edits`, body);
|
|
185
|
+
return renderEditResult(res);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const conflict = editConflictResult(err);
|
|
189
|
+
if (conflict)
|
|
190
|
+
return conflict;
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// orboto_edit_doc_section (POST /docs/:id/edits - section ops, ORB-1341)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
export const editDocSectionToolConfig = {
|
|
199
|
+
title: 'Edit a doc section by heading path (context-efficient)',
|
|
200
|
+
description: 'Change a doc by markdown-heading-addressed section operations - ship only the new section content, not the whole doc. A section is addressed by its heading path (e.g. ["Setup","Credentials"] = a "Credentials" heading nested under "Setup"). Heading matching is EXACT + case-sensitive; the leading path elements must be an ordered ancestor chain (not necessarily immediate parents). op=replace swaps the section body, op=append adds to its end, op=insertAfter inserts directly below the heading line (the heading line itself is never removed). A path that matches zero or multiple headings returns a 409 you can act on (narrow the path to disambiguate). Prefer this over orboto_update_doc for section-scoped rewrites. baseRevisionId gives optimistic concurrency. Returns a short context window per op.',
|
|
201
|
+
inputSchema: z.object({
|
|
202
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable key (ORB-D12 / DOC-5). Find via orboto_search_docs.'),
|
|
203
|
+
sectionOps: z.array(z.object({
|
|
204
|
+
headingPath: z.array(z.string().min(1)).min(1).describe('Ordered heading breadcrumb, top-down. Exact + case-sensitive.'),
|
|
205
|
+
op: z.enum(['replace', 'append', 'insertAfter']).describe('replace body / append to body / insert directly below the heading line.'),
|
|
206
|
+
content: z.string().describe('New markdown content for the op.'),
|
|
207
|
+
})).min(1).max(100).describe('Applied sequentially after any string edits.'),
|
|
208
|
+
baseRevisionId: z.string().uuid().optional().describe('Optimistic-concurrency token = the revision id you last saw. Omit to always apply.'),
|
|
209
|
+
}).shape,
|
|
210
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
211
|
+
};
|
|
212
|
+
export function makeEditDocSectionHandler(client) {
|
|
213
|
+
return async (input) => {
|
|
214
|
+
const docId = await resolveDocId(client, input.docId);
|
|
215
|
+
const body = { sectionOps: input.sectionOps };
|
|
216
|
+
if (input.baseRevisionId)
|
|
217
|
+
body.baseRevisionId = input.baseRevisionId;
|
|
218
|
+
try {
|
|
219
|
+
const res = await client.post(`/docs/${docId}/edits`, body);
|
|
220
|
+
return renderEditResult(res);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
const conflict = editConflictResult(err);
|
|
224
|
+
if (conflict)
|
|
225
|
+
return conflict;
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1342 - doc snippet-search + targeted-edit tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Same fetch-stub harness as docs.test.ts. Each test asserts the outgoing
|
|
5
|
+
* wire shape (URL + method + body) and the structured content the model
|
|
6
|
+
* gets back. The edit tools also cover the two machine-readable 409 paths
|
|
7
|
+
* (ambiguous / stale) the acceptance criteria call out - the API returns a
|
|
8
|
+
* JSON conflict body, the tool must surface it as a non-throwing isError
|
|
9
|
+
* result the model can act on, NOT let the raw OrbotoApiError bubble up.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
13
|
+
import { makeSearchDocsHandler, makeEditDocHandler, makeEditDocSectionHandler, } from './doc-edits.js';
|
|
14
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
15
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
16
|
+
/** Stub fetch with a queue of responses. Error responses carry a `text`
|
|
17
|
+
* body (the OrbotoClient reads res.text() on non-2xx) so the 409 JSON
|
|
18
|
+
* conflict body reaches the tool's catch branch. */
|
|
19
|
+
function stubJSON(responses) {
|
|
20
|
+
const calls = [];
|
|
21
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
22
|
+
calls.push({
|
|
23
|
+
url: url.toString(),
|
|
24
|
+
method: init?.method ?? 'GET',
|
|
25
|
+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
|
|
26
|
+
});
|
|
27
|
+
const r = responses.shift();
|
|
28
|
+
if (!r)
|
|
29
|
+
throw new Error('unexpected extra fetch');
|
|
30
|
+
return {
|
|
31
|
+
ok: r.ok ?? true,
|
|
32
|
+
status: r.status ?? 200,
|
|
33
|
+
statusText: 'OK',
|
|
34
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
35
|
+
text: async () => r.text ?? '',
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
return calls;
|
|
39
|
+
}
|
|
40
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
41
|
+
const DOC_UUID = '33333333-3333-3333-3333-333333333333';
|
|
42
|
+
const REV_UUID = '44444444-4444-4444-4444-444444444444';
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// orboto_search_docs
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('orboto_search_docs', () => {
|
|
47
|
+
const HIT = {
|
|
48
|
+
id: DOC_UUID,
|
|
49
|
+
docKey: 'ORB-D12',
|
|
50
|
+
title: 'Backup Runbook',
|
|
51
|
+
spaceId: '11111111-1111-1111-1111-111111111111',
|
|
52
|
+
spaceName: 'Backend Runbooks',
|
|
53
|
+
projectId: '22222222-2222-2222-2222-222222222222',
|
|
54
|
+
snippet: 'To <mark>restore</mark> a project from backup …',
|
|
55
|
+
headingPath: ['Setup', 'Restore'],
|
|
56
|
+
charOffset: 120,
|
|
57
|
+
lineOffset: 7,
|
|
58
|
+
rank: 0.42,
|
|
59
|
+
url: '/spaces/11111111-1111-1111-1111-111111111111/docs/33333333-3333-3333-3333-333333333333',
|
|
60
|
+
};
|
|
61
|
+
it('GETs /docs/search with the query + limit and surfaces snippet + anchor', async () => {
|
|
62
|
+
const calls = stubJSON([{ json: { items: [HIT], total: 1 } }]);
|
|
63
|
+
const res = await makeSearchDocsHandler(client)({ q: 'restore backup', limit: 10 });
|
|
64
|
+
expect(calls[0].method).toBe('GET');
|
|
65
|
+
expect(calls[0].url).toContain('/docs/search?');
|
|
66
|
+
expect(calls[0].url).toContain('q=restore+backup');
|
|
67
|
+
expect(calls[0].url).toContain('limit=10');
|
|
68
|
+
const sc = res.structuredContent;
|
|
69
|
+
expect(sc.total).toBe(1);
|
|
70
|
+
expect(sc.items[0].headingPath).toEqual(['Setup', 'Restore']);
|
|
71
|
+
expect(sc.items[0].snippet).toContain('<mark>restore</mark>');
|
|
72
|
+
expect(res.content[0].text).toContain('§Setup > Restore');
|
|
73
|
+
});
|
|
74
|
+
it('resolves projectKey to a projectId before searching', async () => {
|
|
75
|
+
const calls = stubJSON([
|
|
76
|
+
{ json: { id: '22222222-2222-2222-2222-222222222222', key: 'ORB', name: 'orboto', status: 'active' } }, // by-key
|
|
77
|
+
{ json: { items: [], total: 0 } },
|
|
78
|
+
]);
|
|
79
|
+
await makeSearchDocsHandler(client)({ q: 'deploy', projectKey: 'ORB' });
|
|
80
|
+
expect(calls[0].url).toContain('/projects/by-key/ORB');
|
|
81
|
+
expect(calls[1].url).toContain('projectId=22222222-2222-2222-2222-222222222222');
|
|
82
|
+
});
|
|
83
|
+
it('passes spaceId straight through', async () => {
|
|
84
|
+
const calls = stubJSON([{ json: { items: [], total: 0 } }]);
|
|
85
|
+
await makeSearchDocsHandler(client)({ q: 'x', spaceId: '11111111-1111-1111-1111-111111111111' });
|
|
86
|
+
expect(calls[0].url).toContain('spaceId=11111111-1111-1111-1111-111111111111');
|
|
87
|
+
});
|
|
88
|
+
it('reports no matches cleanly', async () => {
|
|
89
|
+
stubJSON([{ json: { items: [], total: 0 } }]);
|
|
90
|
+
const res = await makeSearchDocsHandler(client)({ q: 'nothing' });
|
|
91
|
+
expect(res.content[0].text).toContain('No docs matched');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// orboto_edit_doc - string-replace
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
describe('orboto_edit_doc', () => {
|
|
98
|
+
it('POSTs edits to /docs/:id/edits (UUID passthrough) and renders the window', async () => {
|
|
99
|
+
const calls = stubJSON([{
|
|
100
|
+
json: {
|
|
101
|
+
docId: DOC_UUID,
|
|
102
|
+
docKey: 'ORB-D12',
|
|
103
|
+
revisionId: REV_UUID,
|
|
104
|
+
edits: [{ index: 0, kind: 'edit', replaced: 1, window: 'The slow brown fox' }],
|
|
105
|
+
},
|
|
106
|
+
}]);
|
|
107
|
+
const res = await makeEditDocHandler(client)({
|
|
108
|
+
docId: DOC_UUID,
|
|
109
|
+
edits: [{ oldString: 'quick', newString: 'slow' }],
|
|
110
|
+
baseRevisionId: REV_UUID,
|
|
111
|
+
});
|
|
112
|
+
expect(calls[0]).toMatchObject({
|
|
113
|
+
method: 'POST',
|
|
114
|
+
url: `https://orboto.example.com/docs/${DOC_UUID}/edits`,
|
|
115
|
+
body: { edits: [{ oldString: 'quick', newString: 'slow' }], baseRevisionId: REV_UUID },
|
|
116
|
+
});
|
|
117
|
+
const sc = res.structuredContent;
|
|
118
|
+
expect(sc.revisionId).toBe(REV_UUID);
|
|
119
|
+
expect(res.content[0].text).toContain('Applied 1 change');
|
|
120
|
+
});
|
|
121
|
+
it('resolves a doc key to a UUID before editing', async () => {
|
|
122
|
+
const calls = stubJSON([
|
|
123
|
+
{ json: { id: DOC_UUID } }, // /docs/by-key/ORB-D12
|
|
124
|
+
{ json: { docId: DOC_UUID, docKey: 'ORB-D12', revisionId: REV_UUID, edits: [] } },
|
|
125
|
+
]);
|
|
126
|
+
await makeEditDocHandler(client)({ docId: 'ORB-D12', edits: [{ oldString: 'a', newString: 'b' }] });
|
|
127
|
+
expect(calls[0].url).toContain('/docs/by-key/ORB-D12');
|
|
128
|
+
expect(calls[1].url).toBe(`https://orboto.example.com/docs/${DOC_UUID}/edits`);
|
|
129
|
+
});
|
|
130
|
+
it('surfaces an ambiguous-match 409 as a non-throwing isError result', async () => {
|
|
131
|
+
stubJSON([{
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 409,
|
|
134
|
+
text: JSON.stringify({ error: 'ambiguous', reason: 'ambiguous_match', editIndex: 0, occurrences: 3 }),
|
|
135
|
+
}]);
|
|
136
|
+
const res = await makeEditDocHandler(client)({ docId: DOC_UUID, edits: [{ oldString: 'the', newString: 'a' }] });
|
|
137
|
+
expect(res.isError).toBe(true);
|
|
138
|
+
expect(res.content[0].text).toContain('matched 3 times');
|
|
139
|
+
expect(res.structuredContent).toMatchObject({ conflict: true, reason: 'ambiguous_match', occurrences: 3 });
|
|
140
|
+
});
|
|
141
|
+
it('surfaces a stale-revision 409 with the current revision id to retry with', async () => {
|
|
142
|
+
stubJSON([{
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 409,
|
|
145
|
+
text: JSON.stringify({ error: 'stale', reason: 'stale_revision', currentRevisionId: REV_UUID }),
|
|
146
|
+
}]);
|
|
147
|
+
const res = await makeEditDocHandler(client)({
|
|
148
|
+
docId: DOC_UUID,
|
|
149
|
+
edits: [{ oldString: 'a', newString: 'b' }],
|
|
150
|
+
baseRevisionId: '00000000-0000-0000-0000-000000000000',
|
|
151
|
+
});
|
|
152
|
+
expect(res.isError).toBe(true);
|
|
153
|
+
expect(res.content[0].text).toContain(REV_UUID);
|
|
154
|
+
expect(res.structuredContent).toMatchObject({ conflict: true, reason: 'stale_revision', currentRevisionId: REV_UUID });
|
|
155
|
+
});
|
|
156
|
+
it('rethrows a non-409 API error (permission / transport)', async () => {
|
|
157
|
+
stubJSON([{ ok: false, status: 403, text: 'Forbidden' }]);
|
|
158
|
+
await expect(makeEditDocHandler(client)({ docId: DOC_UUID, edits: [{ oldString: 'a', newString: 'b' }] })).rejects.toThrow(/403/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// orboto_edit_doc_section - heading-addressed
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
describe('orboto_edit_doc_section', () => {
|
|
165
|
+
it('POSTs sectionOps to /docs/:id/edits', async () => {
|
|
166
|
+
const calls = stubJSON([{
|
|
167
|
+
json: {
|
|
168
|
+
docId: DOC_UUID,
|
|
169
|
+
docKey: 'ORB-D12',
|
|
170
|
+
revisionId: REV_UUID,
|
|
171
|
+
edits: [{ index: 0, kind: 'sectionOp', replaced: 1, window: 'New creds.' }],
|
|
172
|
+
},
|
|
173
|
+
}]);
|
|
174
|
+
const res = await makeEditDocSectionHandler(client)({
|
|
175
|
+
docId: DOC_UUID,
|
|
176
|
+
sectionOps: [{ headingPath: ['Setup', 'Credentials'], op: 'replace', content: '\nNew creds.\n' }],
|
|
177
|
+
});
|
|
178
|
+
expect(calls[0]).toMatchObject({
|
|
179
|
+
method: 'POST',
|
|
180
|
+
url: `https://orboto.example.com/docs/${DOC_UUID}/edits`,
|
|
181
|
+
body: { sectionOps: [{ headingPath: ['Setup', 'Credentials'], op: 'replace', content: '\nNew creds.\n' }] },
|
|
182
|
+
});
|
|
183
|
+
expect(res.content[0].text).toContain('sectionOp');
|
|
184
|
+
});
|
|
185
|
+
it('surfaces an ambiguous-heading 409 with candidates', async () => {
|
|
186
|
+
stubJSON([{
|
|
187
|
+
ok: false,
|
|
188
|
+
status: 409,
|
|
189
|
+
text: JSON.stringify({
|
|
190
|
+
error: 'ambiguous heading',
|
|
191
|
+
reason: 'ambiguous_heading',
|
|
192
|
+
editIndex: 0,
|
|
193
|
+
headingPath: ['Notes'],
|
|
194
|
+
candidates: ['A > Notes', 'B > Notes'],
|
|
195
|
+
}),
|
|
196
|
+
}]);
|
|
197
|
+
const res = await makeEditDocSectionHandler(client)({
|
|
198
|
+
docId: DOC_UUID,
|
|
199
|
+
sectionOps: [{ headingPath: ['Notes'], op: 'append', content: 'x' }],
|
|
200
|
+
});
|
|
201
|
+
expect(res.isError).toBe(true);
|
|
202
|
+
expect(res.content[0].text).toContain('A > Notes | B > Notes');
|
|
203
|
+
expect(res.structuredContent).toMatchObject({ conflict: true, reason: 'ambiguous_heading' });
|
|
204
|
+
});
|
|
205
|
+
it('surfaces a heading-not-found 409', async () => {
|
|
206
|
+
stubJSON([{
|
|
207
|
+
ok: false,
|
|
208
|
+
status: 409,
|
|
209
|
+
text: JSON.stringify({ error: 'not found', reason: 'heading_not_found', editIndex: 0, headingPath: ['Ghost'] }),
|
|
210
|
+
}]);
|
|
211
|
+
const res = await makeEditDocSectionHandler(client)({
|
|
212
|
+
docId: DOC_UUID,
|
|
213
|
+
sectionOps: [{ headingPath: ['Ghost'], op: 'replace', content: 'x' }],
|
|
214
|
+
});
|
|
215
|
+
expect(res.isError).toBe(true);
|
|
216
|
+
expect(res.content[0].text).toContain('no heading matched');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-915 - doc-export MCP tools (epic ORB-911 Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* - orboto_export_doc_md - GET /docs/:id/export/md (text/markdown)
|
|
5
|
+
* - orboto_export_doc_pdf - POST /docs/:id/export/pdf (application/pdf)
|
|
6
|
+
*
|
|
7
|
+
* The Markdown export differs from `orboto_get_doc.content` because
|
|
8
|
+
* the server-side renderer strips backlinks / smart-link inflation
|
|
9
|
+
* and emits the raw saved Markdown - useful when the agent wants to
|
|
10
|
+
* pipe the body through another tool without the get-doc envelope.
|
|
11
|
+
*
|
|
12
|
+
* The PDF export returns binary bytes. MCP supports binary resource
|
|
13
|
+
* attachments via the `{ type: 'resource', resource: { uri, blob:
|
|
14
|
+
* <base64>, mimeType } }` content shape; we return the PDF that way.
|
|
15
|
+
*
|
|
16
|
+
* Both endpoints rely on `OrbotoClient.getText` / `postBinary` which
|
|
17
|
+
* landed alongside this phase - JSON-only `get` / `post` would have
|
|
18
|
+
* blown up on the non-JSON response bodies.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { resolveDocId } from './docs.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// orboto_export_doc_md
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const exportDocMdToolConfig = {
|
|
26
|
+
title: 'Export a doc page as Markdown',
|
|
27
|
+
description: 'Return the doc body as plain Markdown (text/markdown). Differs from orboto_get_doc.content in that the server-side export endpoint emits the canonical saved body - agents pulling docs to feed into another tool should prefer this over get_doc, which wraps the body in get-doc envelope text + backlinks.',
|
|
28
|
+
inputSchema: z.object({
|
|
29
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
30
|
+
}).shape,
|
|
31
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
32
|
+
};
|
|
33
|
+
export function makeExportDocMdHandler(client) {
|
|
34
|
+
return async ({ docId }) => {
|
|
35
|
+
docId = await resolveDocId(client, docId);
|
|
36
|
+
const markdown = await client.getText(`/docs/${docId}/export/md`);
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: 'text', text: markdown }],
|
|
39
|
+
structuredContent: {
|
|
40
|
+
docId,
|
|
41
|
+
markdown,
|
|
42
|
+
sizeBytes: Buffer.byteLength(markdown, 'utf8'),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// orboto_export_doc_pdf
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
export const exportDocPdfToolConfig = {
|
|
51
|
+
title: 'Export a doc page as PDF',
|
|
52
|
+
description: 'Render the doc page to PDF via the workspace\'s PdfService (Puppeteer-backed) and return the bytes as a base64 MCP resource attachment. Requires the PDF engine to be configured - deployments without Chromium / Puppeteer return 503 (errors.pdf.engine_unavailable) which surfaces as an OrbotoApiError.',
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
|
|
55
|
+
}).shape,
|
|
56
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
57
|
+
};
|
|
58
|
+
export function makeExportDocPdfHandler(client) {
|
|
59
|
+
return async ({ docId }) => {
|
|
60
|
+
docId = await resolveDocId(client, docId);
|
|
61
|
+
const { bytes, contentType } = await client.postBinary(`/docs/${docId}/export/pdf`);
|
|
62
|
+
// MCP's `resource` content type takes a base64 blob - Buffer →
|
|
63
|
+
// base64 is the standard Node path.
|
|
64
|
+
const base64 = Buffer.from(bytes).toString('base64');
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: 'resource',
|
|
69
|
+
resource: {
|
|
70
|
+
uri: `orboto://doc/${docId}/export.pdf`,
|
|
71
|
+
mimeType: contentType,
|
|
72
|
+
blob: base64,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'text',
|
|
77
|
+
text: `Rendered ${docId} to PDF (${Math.round(bytes.byteLength / 1024)} KB).`,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
structuredContent: {
|
|
81
|
+
docId,
|
|
82
|
+
sizeBytes: bytes.byteLength,
|
|
83
|
+
contentType,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-915 - doc-export tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Markdown export: stub fetch returning a string + content-type
|
|
5
|
+
* text/markdown. Verify the tool returns the Markdown verbatim.
|
|
6
|
+
*
|
|
7
|
+
* PDF export: stub fetch returning a Uint8Array body. Verify the
|
|
8
|
+
* tool wraps it as an MCP resource attachment with base64 blob.
|
|
9
|
+
*
|
|
10
|
+
* 503 from a deployment with no PDF engine: verify the
|
|
11
|
+
* OrbotoApiError bubbles up so the model can tell the user what's
|
|
12
|
+
* wrong.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
16
|
+
import { makeExportDocMdHandler, makeExportDocPdfHandler, } from './doc-export.js';
|
|
17
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
18
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
19
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
20
|
+
const DOC_ID = 'd0000000-0000-0000-0000-000000000001';
|
|
21
|
+
function stubText(text, contentType = 'text/markdown; charset=utf-8') {
|
|
22
|
+
const calls = [];
|
|
23
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
24
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
status: 200,
|
|
28
|
+
statusText: 'OK',
|
|
29
|
+
headers: new Headers({ 'content-type': contentType }),
|
|
30
|
+
text: async () => text,
|
|
31
|
+
arrayBuffer: async () => Buffer.from(text, 'utf8').buffer,
|
|
32
|
+
json: async () => { throw new Error('not json'); },
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return calls;
|
|
36
|
+
}
|
|
37
|
+
function stubBinary(bytes, contentType = 'application/pdf') {
|
|
38
|
+
const calls = [];
|
|
39
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
40
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
44
|
+
statusText: 'OK',
|
|
45
|
+
headers: new Headers({ 'content-type': contentType }),
|
|
46
|
+
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
47
|
+
text: async () => '',
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return calls;
|
|
51
|
+
}
|
|
52
|
+
function stub5xx(status, body) {
|
|
53
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
status,
|
|
57
|
+
statusText: 'Server Error',
|
|
58
|
+
headers: new Headers(),
|
|
59
|
+
text: async () => JSON.stringify(body),
|
|
60
|
+
json: async () => body,
|
|
61
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
describe('orboto_export_doc_md', () => {
|
|
66
|
+
it('GETs /docs/:id/export/md and surfaces the raw Markdown', async () => {
|
|
67
|
+
const md = '# My Page\n\nBody text.';
|
|
68
|
+
const calls = stubText(md);
|
|
69
|
+
const res = await makeExportDocMdHandler(client)({ docId: DOC_ID });
|
|
70
|
+
expect(calls[0]).toMatchObject({
|
|
71
|
+
method: 'GET',
|
|
72
|
+
url: `https://orboto.example.com/docs/${DOC_ID}/export/md`,
|
|
73
|
+
});
|
|
74
|
+
expect(res.content[0].text).toBe(md);
|
|
75
|
+
expect(res.structuredContent).toMatchObject({
|
|
76
|
+
docId: DOC_ID,
|
|
77
|
+
markdown: md,
|
|
78
|
+
sizeBytes: Buffer.byteLength(md, 'utf8'),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('orboto_export_doc_pdf', () => {
|
|
83
|
+
it('POSTs /docs/:id/export/pdf and wraps the bytes as a base64 MCP resource', async () => {
|
|
84
|
+
// Pretend the renderer returned a minimal 8-byte PDF header.
|
|
85
|
+
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]);
|
|
86
|
+
const calls = stubBinary(pdfBytes);
|
|
87
|
+
const res = await makeExportDocPdfHandler(client)({ docId: DOC_ID });
|
|
88
|
+
expect(calls[0]).toMatchObject({
|
|
89
|
+
method: 'POST',
|
|
90
|
+
url: `https://orboto.example.com/docs/${DOC_ID}/export/pdf`,
|
|
91
|
+
});
|
|
92
|
+
const first = res.content[0];
|
|
93
|
+
expect(first.type).toBe('resource');
|
|
94
|
+
expect(first.resource.uri).toBe(`orboto://doc/${DOC_ID}/export.pdf`);
|
|
95
|
+
expect(first.resource.mimeType).toBe('application/pdf');
|
|
96
|
+
// The blob is the base64 of the bytes - decode it back and compare.
|
|
97
|
+
const decoded = Buffer.from(first.resource.blob, 'base64');
|
|
98
|
+
expect(Array.from(decoded)).toEqual(Array.from(pdfBytes));
|
|
99
|
+
expect(res.structuredContent).toMatchObject({
|
|
100
|
+
docId: DOC_ID,
|
|
101
|
+
sizeBytes: pdfBytes.byteLength,
|
|
102
|
+
contentType: 'application/pdf',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
it('bubbles up a 503 when the deployment has no PDF engine', async () => {
|
|
106
|
+
stub5xx(503, { error: 'PDF engine unavailable', errorKey: 'errors.pdf.engine_unavailable' });
|
|
107
|
+
await expect(makeExportDocPdfHandler(client)({ docId: DOC_ID })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
108
|
+
});
|
|
109
|
+
});
|