@orboto/mcp 0.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * ORB-244 Phase B - doc tools.
3
+ *
4
+ * `orboto_list_doc_spaces` and `orboto_get_doc` share this file for the
5
+ * same reason the milestone tools do - cheap neighbours on the same
6
+ * API root. Docs carry a human-readable key (`ORB-D12` / `DOC-5`,
7
+ * ORB-1004) and spaces carry one too (`ORB-S1` / `SPACE-5`, ORB-1015);
8
+ * both resolve by key or UUID.
9
+ *
10
+ * ORB-912 (epic ORB-911) added the write-path neighbours so an
11
+ * MCP-aware client can do the full space lifecycle without falling
12
+ * back to REST: create / update / delete spaces, list docs in a
13
+ * space, and (in follow-up phases) write / move / attach / export /
14
+ * roll back individual doc pages.
15
+ *
16
+ * Schema alignment: field names match `@orboto/shared-schema`
17
+ * exactly - `content` not `body` on docs, `excerpt` not `snippet` on
18
+ * hits, backlinks carry `{type, id, label, sourceDocId,
19
+ * sourceDocTitle, sourceSpaceId}`. Getting those wrong 500s the tool
20
+ * because the API's Zod response validator rejects off-shape rows
21
+ * before they leave the server.
22
+ */
23
+ import { z } from 'zod';
24
+ import { resolveProjectByKey } from './shared.js';
25
+ // ---------------------------------------------------------------------------
26
+ // orboto_list_doc_spaces
27
+ // ---------------------------------------------------------------------------
28
+ export const listDocSpacesToolConfig = {
29
+ title: 'List doc spaces',
30
+ description: 'List wiki spaces (global and project-scoped) the caller can read. Each space contains a tree of docs accessed via orboto_get_doc.',
31
+ inputSchema: z.object({}).shape,
32
+ annotations: { readOnlyHint: true, idempotentHint: true },
33
+ };
34
+ export function makeListDocSpacesHandler(client) {
35
+ return async () => {
36
+ const spaces = await client.get('/spaces');
37
+ const text = spaces.length === 0
38
+ ? 'No doc spaces visible to this user.'
39
+ : spaces.map((s) => {
40
+ // `type` is the authoritative scope indicator; `projectId` is
41
+ // populated when type === 'project'. We use the type flag so
42
+ // the text rendering doesn't depend on an extra API join.
43
+ const scope = s.type === 'project' ? 'project-scoped' : 'workspace-wide';
44
+ // ORB-1161 - show key AND uuid. Doc tools accept the key directly,
45
+ // but surface the uuid too for any tool that still needs it.
46
+ return `- ${s.name} (${scope}) - key: ${s.key ?? '(none)'} id: ${s.id}`;
47
+ }).join('\n');
48
+ return {
49
+ content: [{ type: 'text', text }],
50
+ structuredContent: {
51
+ // ORB-1700 - metadata only; description/slug/visibility belong to
52
+ // the follow-up space read. id stays: every doc tool accepts it,
53
+ // and pre-key spaces have no key to address by.
54
+ spaces: spaces.map((s) => ({
55
+ id: s.id,
56
+ key: s.key ?? null,
57
+ name: s.name,
58
+ type: s.type,
59
+ })),
60
+ },
61
+ };
62
+ };
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // orboto_get_doc
66
+ // ---------------------------------------------------------------------------
67
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
68
+ // ORB-1084 - every docId-taking tool accepts the human-readable doc
69
+ // key (ORB-D12 / DOC-5) as well; writes were UUID-only while no tool
70
+ // exposed the full UUID, so agents could read but never write.
71
+ export async function resolveDocId(client, docIdOrKey) {
72
+ if (UUID_RE.test(docIdOrKey))
73
+ return docIdOrKey;
74
+ const doc = await client.get(`/docs/by-key/${encodeURIComponent(docIdOrKey)}`);
75
+ return doc.id;
76
+ }
77
+ export const getDocToolConfig = {
78
+ title: 'Get a doc by id or key',
79
+ description: 'Return the doc content (Markdown) plus backlinks (other docs that reference this doc, or tickets/milestones the doc is linked from). Accepts either the doc UUID or its human-readable key (e.g. ORB-D12 / DOC-5).',
80
+ inputSchema: z.object({
81
+ docId: z.string().min(1).describe('Doc UUID or human-readable key (ORB-D12 / DOC-5). Discover via orboto_list_doc_spaces or orboto_search.'),
82
+ }).shape,
83
+ annotations: { readOnlyHint: true, idempotentHint: true },
84
+ };
85
+ export function makeGetDocHandler(client) {
86
+ return async ({ docId }) => {
87
+ // ORB-1004 - accept a doc key too; resolve via the by-key route, then
88
+ // use the resolved UUID for the backlinks fetch.
89
+ const doc = UUID_RE.test(docId)
90
+ ? await client.get(`/docs/${docId}`)
91
+ : await client.get(`/docs/by-key/${encodeURIComponent(docId)}`);
92
+ const backlinks = await client.get(`/docs/${doc.id}/backlinks`).catch(() => []);
93
+ const lines = [
94
+ `# ${doc.icon ? `${doc.icon} ` : ''}${doc.title}`,
95
+ `${doc.docKey ? `Key: ${doc.docKey} · ` : ''}ID: ${doc.id} · Visibility: ${doc.visibility} · Updated: ${doc.updatedAt}`,
96
+ '',
97
+ doc.content || '_(empty)_',
98
+ ];
99
+ if (backlinks.length > 0) {
100
+ lines.push('', `## Backlinks (${backlinks.length})`);
101
+ for (const b of backlinks) {
102
+ // sourceDocTitle is the doc that links *to* this one; the
103
+ // `type` + `id` pair identifies the target - same row, target
104
+ // side. For most backlinks the target is this doc, so we
105
+ // surface the source.
106
+ lines.push(`- ${b.sourceDocTitle}`);
107
+ }
108
+ }
109
+ return {
110
+ content: [{ type: 'text', text: lines.join('\n') }],
111
+ structuredContent: {
112
+ doc: {
113
+ id: doc.id,
114
+ docKey: doc.docKey,
115
+ title: doc.title,
116
+ content: doc.content,
117
+ visibility: doc.visibility,
118
+ icon: doc.icon,
119
+ updatedAt: doc.updatedAt,
120
+ parentDocId: doc.parentDocId,
121
+ spaceId: doc.spaceId,
122
+ slug: doc.slug,
123
+ },
124
+ backlinks: backlinks.map((b) => ({
125
+ sourceDocId: b.sourceDocId,
126
+ sourceDocTitle: b.sourceDocTitle,
127
+ targetType: b.type,
128
+ label: b.label,
129
+ })),
130
+ },
131
+ };
132
+ };
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // orboto_create_doc_space (ORB-912)
136
+ // ---------------------------------------------------------------------------
137
+ export const createDocSpaceToolConfig = {
138
+ title: 'Create a doc space',
139
+ description: 'Create a new wiki space. IMPORTANT: every project ALREADY has an auto-generated general space (slug `<key>-general`, system-generated, holds the AI primer + manual notes) - do NOT create a second space just to hold a project\'s docs. Call orboto_list_doc_spaces first and reuse the existing project space; only create a new one for a genuinely separate collection. `type=global` creates a workspace-wide space (super-admin only); `type=project` creates a space scoped to a project - pass `projectKey` (e.g. "ORB"); a raw `projectId` UUID is still accepted for back-compat. The slug is derived from `name` when omitted. Returns the new space row.',
140
+ inputSchema: z.object({
141
+ name: z.string().min(1).max(100).describe('Display name.'),
142
+ type: z.enum(['global', 'project']).describe('Scope: workspace-wide or project-scoped.'),
143
+ projectKey: z.string().min(1).optional().describe('Project key (e.g. "ORB"). Required when type=project (or pass projectId).'),
144
+ projectId: z.string().uuid().optional().describe('Project UUID. Back-compat alternative to projectKey when type=project.'),
145
+ description: z.string().nullish(),
146
+ icon: z.string().nullish().describe('Single emoji (e.g. "📘") used in the sidebar tree.'),
147
+ slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional().describe('URL slug. Auto-derived from name when omitted.'),
148
+ }).shape,
149
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
150
+ };
151
+ export function makeCreateDocSpaceHandler(client) {
152
+ return async (input) => {
153
+ const body = { name: input.name, type: input.type };
154
+ if (input.type === 'project') {
155
+ // Prefer the key (agent-facing surfaces speak keys); fall back to a raw
156
+ // UUID for back-compat.
157
+ let projectId = input.projectId;
158
+ if (!projectId && input.projectKey) {
159
+ projectId = (await resolveProjectByKey(client, input.projectKey)).id;
160
+ }
161
+ if (!projectId)
162
+ throw new Error('type=project requires projectKey (or a projectId UUID).');
163
+ body.projectId = projectId;
164
+ }
165
+ else if (input.projectId) {
166
+ body.projectId = input.projectId;
167
+ }
168
+ if (input.description !== undefined)
169
+ body.description = input.description;
170
+ if (input.icon !== undefined)
171
+ body.icon = input.icon;
172
+ if (input.slug)
173
+ body.slug = input.slug;
174
+ const row = await client.post('/spaces', body);
175
+ return {
176
+ content: [{ type: 'text', text: `Created doc space: ${row.name}\n id: ${row.id}\n type: ${row.type}\n slug: ${row.slug}` }],
177
+ structuredContent: {
178
+ id: row.id,
179
+ name: row.name,
180
+ slug: row.slug,
181
+ type: row.type,
182
+ projectId: row.projectId,
183
+ isPublic: row.isPublic,
184
+ visibility: row.visibility ?? null,
185
+ guestsVisible: row.guestsVisible ?? null,
186
+ },
187
+ };
188
+ };
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // orboto_update_doc_space (ORB-912)
192
+ // ---------------------------------------------------------------------------
193
+ export const updateDocSpaceToolConfig = {
194
+ title: 'Update a doc space',
195
+ description: 'Patch a space\'s name / description / icon / slug, and (ORB-1080) its visibility settings: `visibility` (workspace = every internal member, restricted = explicit allowlist via memberIds), `guestsVisible` (external users see the space only when true). Visibility fields require space-management rights (global spaces: super-admin; project spaces: project:edit) - plain membership gets a 403 for those. `isPublic` is deprecated (replaced by guestsVisible). `type` and `projectId` are immutable; auto-generated primer spaces refuse name/slug edits.',
196
+ inputSchema: z.object({
197
+ spaceId: z.string().uuid().describe('Space UUID (find via orboto_list_doc_spaces).'),
198
+ name: z.string().min(1).max(100).optional(),
199
+ description: z.string().nullish(),
200
+ icon: z.string().nullish(),
201
+ isPublic: z.boolean().optional().describe('DEPRECATED - use guestsVisible.'),
202
+ visibility: z.enum(['workspace', 'project_members', 'restricted']).optional().describe('Space-level read gate: workspace = every internal member, project_members = project members whose role grants doc:view (project spaces only), restricted = standalone allowlist. Requires manage rights.'),
203
+ guestsVisible: z.boolean().optional().describe('Explicit guest (external user) opt-in. Requires manage rights.'),
204
+ memberIds: z.array(z.string().uuid()).optional().describe('Replaces the restricted-space allowlist. Requires manage rights.'),
205
+ slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
206
+ }).shape,
207
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
208
+ };
209
+ export function makeUpdateDocSpaceHandler(client) {
210
+ return async (input) => {
211
+ const body = {};
212
+ if (input.name !== undefined)
213
+ body.name = input.name;
214
+ if (input.description !== undefined)
215
+ body.description = input.description;
216
+ if (input.icon !== undefined)
217
+ body.icon = input.icon;
218
+ if (input.isPublic !== undefined)
219
+ body.isPublic = input.isPublic;
220
+ if (input.slug !== undefined)
221
+ body.slug = input.slug;
222
+ // ORB-1080 - visibility settings (manage-gated server-side).
223
+ if (input.visibility !== undefined)
224
+ body.visibility = input.visibility;
225
+ if (input.guestsVisible !== undefined)
226
+ body.guestsVisible = input.guestsVisible;
227
+ if (input.memberIds !== undefined)
228
+ body.memberIds = input.memberIds;
229
+ if (Object.keys(body).length === 0) {
230
+ throw new Error('Pass at least one field to update.');
231
+ }
232
+ const row = await client.patch(`/spaces/${input.spaceId}`, body);
233
+ return {
234
+ content: [{ type: 'text', text: `Updated doc space: ${row.name}\n id: ${row.id}` }],
235
+ structuredContent: {
236
+ id: row.id,
237
+ name: row.name,
238
+ slug: row.slug,
239
+ type: row.type,
240
+ projectId: row.projectId,
241
+ isPublic: row.isPublic,
242
+ visibility: row.visibility ?? null,
243
+ guestsVisible: row.guestsVisible ?? null,
244
+ },
245
+ };
246
+ };
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // orboto_delete_doc_space (ORB-912)
250
+ // ---------------------------------------------------------------------------
251
+ export const deleteDocSpaceToolConfig = {
252
+ title: 'Delete a doc space',
253
+ description: 'DESTRUCTIVE - cascades through every doc in the space (the pages themselves are gone, not just hidden). System-generated project primer spaces refuse deletion (they cascade only when the owning project is deleted). Returns success silently; 404 surfaces as an OrbotoApiError.',
254
+ inputSchema: z.object({
255
+ spaceId: z.string().uuid(),
256
+ }).shape,
257
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
258
+ };
259
+ export function makeDeleteDocSpaceHandler(client) {
260
+ return async ({ spaceId }) => {
261
+ await client.delete(`/spaces/${spaceId}`);
262
+ return {
263
+ content: [{ type: 'text', text: `Doc space ${spaceId} deleted.` }],
264
+ structuredContent: { spaceId, deleted: true },
265
+ };
266
+ };
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // orboto_list_docs_in_space (ORB-912)
270
+ // ---------------------------------------------------------------------------
271
+ export const listDocsInSpaceToolConfig = {
272
+ title: 'List docs inside a space',
273
+ description: 'Return the flat list of doc pages in a space - each carries `parentDocId` so the caller can reconstruct the tree client-side. Use this when an agent needs to find a doc by title rather than asking for a UUID first. Pair with orboto_get_doc to read individual page bodies.',
274
+ inputSchema: z.object({
275
+ spaceId: z.string().min(1).describe('Space key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
276
+ }).shape,
277
+ annotations: { readOnlyHint: true, idempotentHint: true },
278
+ };
279
+ export function makeListDocsInSpaceHandler(client) {
280
+ return async ({ spaceId }) => {
281
+ const rows = await client.get(`/spaces/${spaceId}/docs`);
282
+ if (rows.length === 0) {
283
+ return {
284
+ content: [{ type: 'text', text: 'No docs in this space.' }],
285
+ structuredContent: { docs: [] },
286
+ };
287
+ }
288
+ // Index by parent so we can render a simple indented tree. The
289
+ // tree-walk runs in JS - the API hands us the flat list because
290
+ // sort-order is per-parent and traversal is the caller's concern.
291
+ const byParent = new Map();
292
+ for (const r of rows) {
293
+ const key = r.parentDocId ?? null;
294
+ const arr = byParent.get(key) ?? [];
295
+ arr.push(r);
296
+ byParent.set(key, arr);
297
+ }
298
+ for (const arr of byParent.values()) {
299
+ arr.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title));
300
+ }
301
+ const lines = [];
302
+ const walk = (parentId, depth) => {
303
+ const children = byParent.get(parentId) ?? [];
304
+ for (const c of children) {
305
+ const indent = ' '.repeat(depth);
306
+ const iconPart = c.icon ? `${c.icon} ` : '';
307
+ // ORB-1004 - show the typeable key (falls back to UUID).
308
+ lines.push(`${indent}- ${iconPart}${c.title} (${c.docKey ?? c.id})`);
309
+ walk(c.id, depth + 1);
310
+ }
311
+ };
312
+ walk(null, 0);
313
+ return {
314
+ content: [{ type: 'text', text: lines.join('\n') }],
315
+ structuredContent: {
316
+ docs: rows.map((r) => ({
317
+ id: r.id,
318
+ docKey: r.docKey,
319
+ title: r.title,
320
+ slug: r.slug,
321
+ parentDocId: r.parentDocId,
322
+ visibility: r.visibility,
323
+ icon: r.icon,
324
+ sortOrder: r.sortOrder,
325
+ updatedAt: r.updatedAt,
326
+ })),
327
+ },
328
+ };
329
+ };
330
+ }
331
+ // ---------------------------------------------------------------------------
332
+ // orboto_create_doc (ORB-913)
333
+ // ---------------------------------------------------------------------------
334
+ export const createDocToolConfig = {
335
+ title: 'Create a doc page',
336
+ description: 'Create a new wiki page in `spaceId` with the supplied Markdown body. Unlike orboto_ingest_url / orboto_ingest_file (which derive content from an external source), this is the plain "I have the Markdown, make me a page" path. Title is required; content is optional (creates an empty page). Returns the new doc row including its UUID and its human-readable doc key (e.g. ORB-D12).',
337
+ inputSchema: z.object({
338
+ spaceId: z.string().min(1).describe('Target doc space - key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
339
+ title: z.string().min(1).max(255),
340
+ content: z.string().optional().describe('Markdown body. Pass an empty string or omit for a blank page.'),
341
+ parentDocId: z.string().uuid().nullable().optional().describe('Nest under another doc.'),
342
+ visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional().describe('Defaults to "workspace".'),
343
+ icon: z.string().nullish().describe('Single emoji shown in the tree.'),
344
+ }).shape,
345
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
346
+ };
347
+ export function makeCreateDocHandler(client) {
348
+ return async (input) => {
349
+ const body = { title: input.title };
350
+ if (input.content !== undefined)
351
+ body.content = input.content;
352
+ if (input.parentDocId !== undefined)
353
+ body.parentDocId = input.parentDocId;
354
+ if (input.visibility)
355
+ body.visibility = input.visibility;
356
+ if (input.icon !== undefined)
357
+ body.icon = input.icon;
358
+ const row = await client.post(`/spaces/${input.spaceId}/docs`, body);
359
+ return {
360
+ content: [{ type: 'text', text: `Created doc: ${row.title}\n key: ${row.docKey ?? '(none)'}\n id: ${row.id}\n slug: ${row.slug}\n visibility: ${row.visibility}` }],
361
+ structuredContent: {
362
+ id: row.id,
363
+ docKey: row.docKey,
364
+ title: row.title,
365
+ slug: row.slug,
366
+ spaceId: row.spaceId,
367
+ parentDocId: row.parentDocId,
368
+ visibility: row.visibility,
369
+ },
370
+ };
371
+ };
372
+ }
373
+ // ---------------------------------------------------------------------------
374
+ // orboto_update_doc (ORB-913)
375
+ // ---------------------------------------------------------------------------
376
+ export const updateDocToolConfig = {
377
+ title: 'Update a doc page',
378
+ description: 'Patch a doc\'s title / content / visibility / parent / icon. Content-only updates do not require sending the title. Every body or title change snapshots the previous version into the revision history automatically - agents can roll back via orboto_restore_doc_revision (when that tool ships in Phase 5). Re-embeds the doc when content or title change.',
379
+ inputSchema: z.object({
380
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
381
+ title: z.string().min(1).max(255).optional(),
382
+ content: z.string().optional().describe('Markdown body. Send the full new content; the API replaces, not appends.'),
383
+ parentDocId: z.string().uuid().nullable().optional(),
384
+ visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional(),
385
+ icon: z.string().nullish(),
386
+ }).shape,
387
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
388
+ };
389
+ export function makeUpdateDocHandler(client) {
390
+ return async (input) => {
391
+ input.docId = await resolveDocId(client, input.docId);
392
+ const body = {};
393
+ if (input.title !== undefined)
394
+ body.title = input.title;
395
+ if (input.content !== undefined)
396
+ body.content = input.content;
397
+ if (input.parentDocId !== undefined)
398
+ body.parentDocId = input.parentDocId;
399
+ if (input.visibility !== undefined)
400
+ body.visibility = input.visibility;
401
+ if (input.icon !== undefined)
402
+ body.icon = input.icon;
403
+ if (Object.keys(body).length === 0) {
404
+ throw new Error('Pass at least one field to update.');
405
+ }
406
+ const row = await client.patch(`/docs/${input.docId}`, body);
407
+ return {
408
+ content: [{ type: 'text', text: `Updated doc: ${row.title}\n id: ${row.id}\n updatedAt: ${row.updatedAt}` }],
409
+ structuredContent: {
410
+ id: row.id,
411
+ title: row.title,
412
+ spaceId: row.spaceId,
413
+ parentDocId: row.parentDocId,
414
+ visibility: row.visibility,
415
+ updatedAt: row.updatedAt,
416
+ },
417
+ };
418
+ };
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // orboto_delete_doc (ORB-913)
422
+ // ---------------------------------------------------------------------------
423
+ export const deleteDocToolConfig = {
424
+ title: 'Delete a doc page',
425
+ description: 'DESTRUCTIVE - removes the page from the tree. Revisions are NOT auto-cascaded; the API keeps them so an admin could in principle resurrect from history, but the page itself is gone from listings. System-generated primer docs refuse deletion (the regenerator re-creates them). Returns success silently; 404 is also silent (idempotent).',
426
+ inputSchema: z.object({
427
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
428
+ }).shape,
429
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
430
+ };
431
+ export function makeDeleteDocHandler(client) {
432
+ return async ({ docId }) => {
433
+ docId = await resolveDocId(client, docId);
434
+ await client.delete(`/docs/${docId}`);
435
+ return {
436
+ content: [{ type: 'text', text: `Doc ${docId} deleted.` }],
437
+ structuredContent: { docId, deleted: true },
438
+ };
439
+ };
440
+ }
441
+ // ---------------------------------------------------------------------------
442
+ // orboto_move_doc (ORB-913)
443
+ // ---------------------------------------------------------------------------
444
+ export const moveDocToolConfig = {
445
+ title: 'Move a doc page (reparent / reorder / cross-space)',
446
+ description: 'Reparent a doc, change its sort order, or move it into a different space. All three target fields are optional but at least one must be supplied. Distinct from orboto_update_doc\'s parentDocId because the API endpoint emits a different WS event for the tree-rebuild path connected clients care about.',
447
+ inputSchema: z.object({
448
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
449
+ parentDocId: z.string().uuid().nullable().optional().describe('null = top-level; UUID = nest under that doc.'),
450
+ spaceId: z.string().uuid().optional().describe('Move into a different space. Caller must be member of both.'),
451
+ sortOrder: z.number().int().optional(),
452
+ }).shape,
453
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
454
+ };
455
+ export function makeMoveDocHandler(client) {
456
+ return async (input) => {
457
+ input.docId = await resolveDocId(client, input.docId);
458
+ const body = {};
459
+ if (input.parentDocId !== undefined)
460
+ body.parentDocId = input.parentDocId;
461
+ if (input.spaceId !== undefined)
462
+ body.spaceId = input.spaceId;
463
+ if (input.sortOrder !== undefined)
464
+ body.sortOrder = input.sortOrder;
465
+ if (Object.keys(body).length === 0) {
466
+ throw new Error('Pass at least one of parentDocId / spaceId / sortOrder.');
467
+ }
468
+ const row = await client.post(`/docs/${input.docId}/move`, body);
469
+ return {
470
+ content: [{ type: 'text', text: `Moved doc ${row.title}\n spaceId: ${row.spaceId}\n parentDocId: ${row.parentDocId ?? '(root)'}\n sortOrder: ${row.sortOrder}` }],
471
+ structuredContent: {
472
+ id: row.id,
473
+ spaceId: row.spaceId,
474
+ parentDocId: row.parentDocId,
475
+ sortOrder: row.sortOrder,
476
+ },
477
+ };
478
+ };
479
+ }
480
+ // ---------------------------------------------------------------------------
481
+ // orboto_duplicate_doc_space (ORB-918)
482
+ // ---------------------------------------------------------------------------
483
+ export const duplicateDocSpaceToolConfig = {
484
+ title: 'Duplicate a doc space (clone the space + every doc inside it)',
485
+ description: 'Fork a doc space along with its entire doc tree. The new space\'s name becomes `<source> (copy)`, parent-child relationships in the tree are preserved via UUID remap. Useful when an agent wants to iterate on a runbook space without mutating the original.',
486
+ inputSchema: z.object({
487
+ spaceId: z.string().uuid().describe('Source space to duplicate.'),
488
+ }).shape,
489
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
490
+ };
491
+ export function makeDuplicateDocSpaceHandler(client) {
492
+ return async ({ spaceId }) => {
493
+ const row = await client.post(`/spaces/${spaceId}/duplicate`, {});
494
+ return {
495
+ content: [{
496
+ type: 'text',
497
+ text: `Duplicated space ${spaceId} → ${row.name}\n id: ${row.id}\n slug: ${row.slug}`,
498
+ }],
499
+ structuredContent: {
500
+ sourceSpaceId: spaceId,
501
+ newSpaceId: row.id,
502
+ name: row.name,
503
+ slug: row.slug,
504
+ type: row.type,
505
+ projectId: row.projectId,
506
+ },
507
+ };
508
+ };
509
+ }
510
+ export const resolveDocSmartLinksToolConfig = {
511
+ title: 'Batch-resolve smart-link references to display metadata',
512
+ description: 'Resolve `[[doc:UUID]]` / `[[ticket:UUID]]` / `[[milestone:UUID]]` / `[[project:UUID]]` / commit-hash references to their current title + URL. Visibility-filtered - items the caller is not allowed to see come back as missing (the rendering frontend falls back to the literal label in that case). Useful when reading a doc body that contains many tokens and you want to display them with current titles in one round-trip rather than N+1 get-* calls. Max 200 items per call.',
513
+ inputSchema: z.object({
514
+ items: z.array(z.object({
515
+ type: z.enum(['doc', 'ticket', 'milestone', 'project', 'commit']),
516
+ id: z.string().min(1).max(64).describe('UUID for the entity types; full or short hex hash for commit.'),
517
+ })).min(1).max(200),
518
+ }).shape,
519
+ annotations: { readOnlyHint: true, idempotentHint: true },
520
+ };
521
+ export function makeResolveDocSmartLinksHandler(client) {
522
+ return async ({ items }) => {
523
+ const resolved = await client.post('/docs/resolve-links', { items });
524
+ const resolvedKeys = new Set(resolved.map((r) => `${r.type}:${r.id}`));
525
+ const missing = items.filter((i) => !resolvedKeys.has(`${i.type}:${i.id}`));
526
+ const lines = resolved.map((r) => {
527
+ const extra = r.type === 'ticket' && r.ticketKey
528
+ ? ` [${r.ticketKey}]`
529
+ : r.type === 'commit' && r.commitShort
530
+ ? ` (${r.commitShort})`
531
+ : '';
532
+ return `- ${r.type}:${r.id} → ${r.title}${extra} · ${r.url}`;
533
+ });
534
+ if (missing.length > 0) {
535
+ lines.push('', `Unresolved (visibility or missing) - ${missing.length}:`);
536
+ for (const m of missing)
537
+ lines.push(` - ${m.type}:${m.id}`);
538
+ }
539
+ return {
540
+ content: [{ type: 'text', text: lines.join('\n') || 'No resolvable references.' }],
541
+ structuredContent: {
542
+ resolved: resolved.map((r) => ({
543
+ type: r.type,
544
+ id: r.id,
545
+ title: r.title,
546
+ url: r.url,
547
+ projectKey: r.projectKey ?? null,
548
+ ticketKey: r.ticketKey ?? null,
549
+ commitShort: r.commitShort ?? null,
550
+ commitAuthor: r.commitAuthor ?? null,
551
+ commitProvider: r.commitProvider ?? null,
552
+ })),
553
+ unresolved: missing,
554
+ },
555
+ };
556
+ };
557
+ }