@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,136 @@
1
+ /**
2
+ * ORB-914 - doc-attachments tool tests.
3
+ *
4
+ * Multipart upload's wire format is opaque to JSON inspection - we
5
+ * just assert URL + method + that the FormData carries a `file` field.
6
+ * The embed branch is covered by checking that a follow-up PATCH lands
7
+ * on /docs/:id with the appended Markdown line.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoClient } from '../orboto-client.js';
11
+ import { makeUploadDocAttachmentHandler, makeListDocAttachmentsHandler, makeDeleteDocAttachmentHandler, } from './doc-attachments.js';
12
+ beforeEach(() => { vi.restoreAllMocks(); });
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
15
+ const DOC_ID = 'd0000000-0000-0000-0000-000000000001';
16
+ const ATT_ID = 'a0000000-0000-0000-0000-000000000001';
17
+ const ATTACHMENT = {
18
+ id: ATT_ID,
19
+ targetType: 'doc',
20
+ targetId: DOC_ID,
21
+ filename: 'arch.png',
22
+ contentType: 'image/png',
23
+ sizeBytes: 4096,
24
+ uploadedBy: 'u1',
25
+ uploadedAt: '2026-05-17T13:30:00.000Z',
26
+ downloadUrl: `/attachments/${ATT_ID}`,
27
+ };
28
+ function stubFetch(responses) {
29
+ const calls = [];
30
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
31
+ calls.push({
32
+ url: url.toString(),
33
+ method: init?.method ?? 'GET',
34
+ body: init?.body,
35
+ });
36
+ const r = responses.shift();
37
+ if (!r)
38
+ throw new Error('unexpected extra fetch');
39
+ return {
40
+ ok: r.ok ?? true,
41
+ status: r.status ?? 200,
42
+ statusText: 'OK',
43
+ json: async () => ('json' in r ? r.json : {}),
44
+ text: async () => '',
45
+ };
46
+ });
47
+ return calls;
48
+ }
49
+ const TINY_PNG_BASE64 =
50
+ // 1x1 red png - smallest valid bytes we can pass through
51
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAFhAJ/wlseKgAAAABJRU5ErkJggg==';
52
+ describe('orboto_upload_doc_attachment', () => {
53
+ it('POSTs multipart to /docs/:id/attachments and returns markdown image line', async () => {
54
+ const calls = stubFetch([{ status: 201, json: ATTACHMENT }]);
55
+ const res = await makeUploadDocAttachmentHandler(client)({
56
+ docId: DOC_ID,
57
+ filename: 'arch.png',
58
+ contentBase64: TINY_PNG_BASE64,
59
+ });
60
+ expect(calls[0]).toMatchObject({
61
+ method: 'POST',
62
+ url: `https://orboto.example.com/docs/${DOC_ID}/attachments`,
63
+ });
64
+ // FormData reaches fetch as a FormData instance.
65
+ expect(calls[0].body).toBeInstanceOf(FormData);
66
+ const fd = calls[0].body;
67
+ expect(fd.get('file')).toBeTruthy();
68
+ expect(res.structuredContent).toMatchObject({
69
+ attachmentId: ATT_ID,
70
+ filename: 'arch.png',
71
+ downloadUrl: `/attachments/${ATT_ID}`,
72
+ markdown: `![arch.png](/attachments/${ATT_ID})`,
73
+ embedded: false,
74
+ });
75
+ });
76
+ it('embeds the markdown line into the doc body when embed=true', async () => {
77
+ const calls = stubFetch([
78
+ { status: 201, json: ATTACHMENT },
79
+ { json: { id: DOC_ID, spaceId: 's1', parentDocId: null, title: 'Arch', content: '# Header\n\nbody', slug: 'arch', visibility: 'workspace', icon: null, sortOrder: 0, updatedAt: '2026-05-17T13:30:00.000Z' } },
80
+ { json: { id: DOC_ID, content: '# Header\n\nbody\n\n![arch.png](/attachments/' + ATT_ID + ')' } },
81
+ ]);
82
+ await makeUploadDocAttachmentHandler(client)({
83
+ docId: DOC_ID,
84
+ filename: 'arch.png',
85
+ contentBase64: TINY_PNG_BASE64,
86
+ embed: true,
87
+ });
88
+ expect(calls[1]).toMatchObject({ method: 'GET', url: `https://orboto.example.com/docs/${DOC_ID}` });
89
+ expect(calls[2]).toMatchObject({ method: 'PATCH', url: `https://orboto.example.com/docs/${DOC_ID}` });
90
+ const patchBody = JSON.parse(calls[2].body);
91
+ expect(patchBody.content).toContain(`![arch.png](/attachments/${ATT_ID})`);
92
+ expect(patchBody.content).toContain('# Header');
93
+ });
94
+ it('refuses an empty-bytes upload before hitting the API', async () => {
95
+ const calls = stubFetch([]);
96
+ await expect(makeUploadDocAttachmentHandler(client)({ docId: DOC_ID, filename: 'empty.png', contentBase64: '' })).rejects.toThrow();
97
+ expect(calls).toHaveLength(0);
98
+ });
99
+ it('uses link syntax (not image) for non-image MIME types', async () => {
100
+ stubFetch([{ status: 201, json: { ...ATTACHMENT, filename: 'spec.pdf', contentType: 'application/pdf' } }]);
101
+ const res = await makeUploadDocAttachmentHandler(client)({
102
+ docId: DOC_ID,
103
+ filename: 'spec.pdf',
104
+ contentBase64: TINY_PNG_BASE64,
105
+ });
106
+ expect(res.structuredContent).toMatchObject({
107
+ markdown: `[spec.pdf](/attachments/${ATT_ID})`,
108
+ });
109
+ });
110
+ });
111
+ describe('orboto_list_doc_attachments', () => {
112
+ it('renders one line per attachment with KB + URL', async () => {
113
+ const calls = stubFetch([{ json: [ATTACHMENT] }]);
114
+ const res = await makeListDocAttachmentsHandler(client)({ docId: DOC_ID });
115
+ expect(calls[0]).toMatchObject({ method: 'GET', url: `https://orboto.example.com/docs/${DOC_ID}/attachments` });
116
+ expect(res.content[0].text).toContain('arch.png');
117
+ expect(res.content[0].text).toContain('4 KB');
118
+ });
119
+ it('reports the empty-list case explicitly', async () => {
120
+ stubFetch([{ json: [] }]);
121
+ const res = await makeListDocAttachmentsHandler(client)({ docId: DOC_ID });
122
+ expect(res.content[0].text).toContain('No attachments');
123
+ expect(res.structuredContent).toMatchObject({ attachments: [] });
124
+ });
125
+ });
126
+ describe('orboto_delete_doc_attachment', () => {
127
+ it('DELETEs /docs/:id/attachments/:attId', async () => {
128
+ const calls = stubFetch([{ status: 204, json: undefined }]);
129
+ const res = await makeDeleteDocAttachmentHandler(client)({ docId: DOC_ID, attachmentId: ATT_ID });
130
+ expect(calls[0]).toMatchObject({
131
+ method: 'DELETE',
132
+ url: `https://orboto.example.com/docs/${DOC_ID}/attachments/${ATT_ID}`,
133
+ });
134
+ expect(res.structuredContent).toMatchObject({ deleted: true });
135
+ });
136
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ORB-917 - doc-comments MCP tools (epic ORB-911 Phase 6).
3
+ *
4
+ * - orboto_list_doc_comments - GET /docs/:id/comments (cursor-paged, oldest-first)
5
+ * - orboto_post_doc_comment - POST /docs/:id/comments
6
+ * - orboto_resolve_doc_comment - POST /docs/:id/comments/:cid/resolve
7
+ * - orboto_delete_doc_comment - DELETE /docs/:id/comments/:cid
8
+ *
9
+ * Comments support replies (single-level - replying to a reply lands as
10
+ * a sibling of the original reply because the API auto-flattens past
11
+ * one level) and optional anchors (a 3-tuple of {text, before, after}
12
+ * the frontend uses to re-locate a highlight even after the doc body
13
+ * has shifted).
14
+ *
15
+ * resolve acts on the thread root by design so resolving from a reply
16
+ * folds the whole conversation - mirrors the web UI's behaviour.
17
+ */
18
+ import { z } from 'zod';
19
+ import { resolveDocId } from './docs.js';
20
+ // ---------------------------------------------------------------------------
21
+ // orboto_list_doc_comments
22
+ // ---------------------------------------------------------------------------
23
+ export const listDocCommentsToolConfig = {
24
+ title: 'List comments on a doc page',
25
+ description: 'Return comments on a doc page, oldest-first so the reply tree reads top-to-bottom. Each row carries the author name + content + resolved state. Cursor-paged - pass `cursor` from the previous call\'s nextCursor 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(),
29
+ cursor: z.string().optional(),
30
+ }).shape,
31
+ annotations: { readOnlyHint: true, idempotentHint: true },
32
+ };
33
+ export function makeListDocCommentsHandler(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 page = await client.get(`/docs/${docId}/comments${query ? `?${query}` : ''}`);
43
+ if (page.items.length === 0) {
44
+ return {
45
+ content: [{ type: 'text', text: 'No comments on this doc.' }],
46
+ structuredContent: { comments: [], nextCursor: null },
47
+ };
48
+ }
49
+ // Build a parent-keyed tree so the Markdown rendering can indent
50
+ // replies one level under their root. The API already flattens
51
+ // past one level, so depth is always 0 or 1.
52
+ const byParent = new Map();
53
+ for (const c of page.items) {
54
+ const key = c.parentCommentId ?? null;
55
+ const arr = byParent.get(key) ?? [];
56
+ arr.push(c);
57
+ byParent.set(key, arr);
58
+ }
59
+ const lines = [];
60
+ const renderOne = (c, depth) => {
61
+ const indent = ' '.repeat(depth);
62
+ const author = c.userName ?? `user ${c.userId.slice(0, 8)}`;
63
+ const resolved = c.resolvedAt ? ' [resolved]' : '';
64
+ lines.push(`${indent}- ${author} · ${c.createdAt}${resolved} · id: ${c.id}`);
65
+ // Anchor preview helps the model understand what part of the
66
+ // doc the comment is anchored to. Truncate to keep the output
67
+ // compact.
68
+ if (c.anchor) {
69
+ const snippet = c.anchor.text.length > 80 ? c.anchor.text.slice(0, 77) + '...' : c.anchor.text;
70
+ lines.push(`${indent} anchored on: "${snippet}"`);
71
+ }
72
+ const body = c.content.split('\n').map((l) => `${indent} ${l}`).join('\n');
73
+ lines.push(body);
74
+ // Render direct replies one level deeper.
75
+ const replies = byParent.get(c.id) ?? [];
76
+ for (const r of replies)
77
+ renderOne(r, depth + 1);
78
+ };
79
+ for (const root of byParent.get(null) ?? []) {
80
+ renderOne(root, 0);
81
+ lines.push(''); // blank line between root threads
82
+ }
83
+ if (page.nextCursor) {
84
+ lines.push(`(more available - pass cursor: ${page.nextCursor})`);
85
+ }
86
+ return {
87
+ content: [{ type: 'text', text: lines.join('\n').trimEnd() }],
88
+ structuredContent: {
89
+ comments: page.items.map((c) => ({
90
+ id: c.id,
91
+ docId: c.docId,
92
+ userId: c.userId,
93
+ userName: c.userName,
94
+ parentCommentId: c.parentCommentId,
95
+ content: c.content,
96
+ anchor: c.anchor,
97
+ resolvedAt: c.resolvedAt,
98
+ resolvedBy: c.resolvedBy,
99
+ resolvedByName: c.resolvedByName,
100
+ createdAt: c.createdAt,
101
+ })),
102
+ nextCursor: page.nextCursor,
103
+ },
104
+ };
105
+ };
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // orboto_post_doc_comment
109
+ // ---------------------------------------------------------------------------
110
+ export const postDocCommentToolConfig = {
111
+ title: 'Post a comment on a doc page (or reply to one)',
112
+ description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mentions in the content body (`@username`) fire notifications automatically.',
113
+ inputSchema: z.object({
114
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
115
+ content: z.string().min(1).max(4000),
116
+ parentCommentId: z.string().uuid().optional().describe('Reply to an existing comment.'),
117
+ anchor: z.object({
118
+ text: z.string().min(1).max(400).describe('The highlighted body text the comment is anchored to.'),
119
+ before: z.string().max(80).describe('Characters before the highlight, for anchor re-discovery.'),
120
+ after: z.string().max(80).describe('Characters after the highlight, for anchor re-discovery.'),
121
+ }).optional(),
122
+ }).shape,
123
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
124
+ };
125
+ export function makePostDocCommentHandler(client) {
126
+ return async ({ docId, content, parentCommentId, anchor }) => {
127
+ docId = await resolveDocId(client, docId);
128
+ const body = { content };
129
+ if (parentCommentId)
130
+ body.parentCommentId = parentCommentId;
131
+ if (anchor)
132
+ body.anchor = anchor;
133
+ const row = await client.post(`/docs/${docId}/comments`, body);
134
+ return {
135
+ content: [{
136
+ type: 'text',
137
+ text: `Posted comment ${row.id} on doc ${docId}${row.parentCommentId ? ` (reply to ${row.parentCommentId})` : ''}.`,
138
+ }],
139
+ structuredContent: {
140
+ id: row.id,
141
+ docId: row.docId,
142
+ parentCommentId: row.parentCommentId,
143
+ content: row.content,
144
+ anchor: row.anchor,
145
+ createdAt: row.createdAt,
146
+ },
147
+ };
148
+ };
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // orboto_resolve_doc_comment
152
+ // ---------------------------------------------------------------------------
153
+ export const resolveDocCommentToolConfig = {
154
+ title: 'Mark a doc comment thread as resolved (or reopen it)',
155
+ description: 'Toggle a comment thread\'s resolved state. Pass `resolved=true` to fold the conversation, `resolved=false` to reopen. Always acts on the thread ROOT - resolving from a reply folds the whole conversation, same as the web UI.',
156
+ inputSchema: z.object({
157
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
158
+ commentId: z.string().uuid(),
159
+ resolved: z.boolean(),
160
+ }).shape,
161
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
162
+ };
163
+ export function makeResolveDocCommentHandler(client) {
164
+ return async ({ docId, commentId, resolved }) => {
165
+ docId = await resolveDocId(client, docId);
166
+ const row = await client.post(`/docs/${docId}/comments/${commentId}/resolve`, { resolved });
167
+ return {
168
+ content: [{
169
+ type: 'text',
170
+ text: `Comment thread ${row.id} ${resolved ? 'resolved' : 'reopened'}.`,
171
+ }],
172
+ structuredContent: {
173
+ id: row.id,
174
+ resolvedAt: row.resolvedAt,
175
+ resolvedBy: row.resolvedBy,
176
+ },
177
+ };
178
+ };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // orboto_update_doc_comment - ORB-933
182
+ // ---------------------------------------------------------------------------
183
+ export const updateDocCommentToolConfig = {
184
+ title: 'Edit your own doc comment',
185
+ description: 'Edit a doc comment\'s body. Author OR super-admin can edit; everyone else gets a 403. Doc-comments do not carry a revision history today (unlike ticket comments), so the prior content is overwritten in place. A no-op call (same content as before) returns the unchanged row.',
186
+ inputSchema: z.object({
187
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
188
+ commentId: z.string().uuid(),
189
+ content: z.string().min(1).max(4000),
190
+ }).shape,
191
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
192
+ };
193
+ export function makeUpdateDocCommentHandler(client) {
194
+ return async ({ docId, commentId, content }) => {
195
+ docId = await resolveDocId(client, docId);
196
+ const row = await client.patch(`/docs/${docId}/comments/${commentId}`, { content });
197
+ return {
198
+ content: [{ type: 'text', text: `Doc comment ${row.id} updated.` }],
199
+ structuredContent: {
200
+ id: row.id,
201
+ docId: row.docId,
202
+ content: row.content,
203
+ },
204
+ };
205
+ };
206
+ }
207
+ // ---------------------------------------------------------------------------
208
+ // orboto_delete_doc_comment
209
+ // ---------------------------------------------------------------------------
210
+ export const deleteDocCommentToolConfig = {
211
+ title: 'Delete a doc comment',
212
+ description: 'DESTRUCTIVE - drops the comment + (via FK cascade) every reply under it. Only the author can delete their own comments; super-admins can delete anyone\'s. Returns success silently; 403 surfaces as an OrbotoApiError.',
213
+ inputSchema: z.object({
214
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
215
+ commentId: z.string().uuid(),
216
+ }).shape,
217
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
218
+ };
219
+ export function makeDeleteDocCommentHandler(client) {
220
+ return async ({ docId, commentId }) => {
221
+ docId = await resolveDocId(client, docId);
222
+ await client.delete(`/docs/${docId}/comments/${commentId}`);
223
+ return {
224
+ content: [{ type: 'text', text: `Comment ${commentId} deleted from doc ${docId}.` }],
225
+ structuredContent: { docId, commentId, deleted: true },
226
+ };
227
+ };
228
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * ORB-917 - doc-comments tool tests.
3
+ *
4
+ * - list: happy + tree-render + empty + nextCursor
5
+ * - post: happy + reply (parentCommentId) + anchor passthrough
6
+ * - resolve: true + false toggle
7
+ * - delete: happy + 403 ownership bubble
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
11
+ import { makeListDocCommentsHandler, makePostDocCommentHandler, makeResolveDocCommentHandler, makeUpdateDocCommentHandler, makeDeleteDocCommentHandler, } from './doc-comments.js';
12
+ beforeEach(() => { vi.restoreAllMocks(); });
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
15
+ const DOC_ID = 'd0000000-0000-0000-0000-000000000001';
16
+ const COMMENT_ID = 'c0000000-0000-0000-0000-000000000001';
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 ROOT_COMMENT = {
39
+ id: COMMENT_ID,
40
+ docId: DOC_ID,
41
+ userId: 'u1',
42
+ userName: 'Alice',
43
+ parentCommentId: null,
44
+ content: 'Looks good to me.',
45
+ anchor: null,
46
+ resolvedAt: null,
47
+ resolvedBy: null,
48
+ createdAt: '2026-05-17T12:00:00.000Z',
49
+ };
50
+ const REPLY_COMMENT = {
51
+ ...ROOT_COMMENT,
52
+ id: 'c0000000-0000-0000-0000-000000000002',
53
+ userId: 'u2',
54
+ userName: 'Bob',
55
+ parentCommentId: ROOT_COMMENT.id,
56
+ content: 'Agreed.',
57
+ createdAt: '2026-05-17T12:05:00.000Z',
58
+ };
59
+ describe('orboto_list_doc_comments', () => {
60
+ it('renders an indented tree with root + replies', async () => {
61
+ stubJSON([{ json: { items: [ROOT_COMMENT, REPLY_COMMENT], nextCursor: null } }]);
62
+ const res = await makeListDocCommentsHandler(client)({ docId: DOC_ID });
63
+ const text = res.content[0].text;
64
+ expect(text.indexOf('Alice')).toBeLessThan(text.indexOf('Bob'));
65
+ expect(text).toMatch(/\s\s- Bob/); // reply is indented one level
66
+ expect(text).toContain('Agreed.');
67
+ const comments = res.structuredContent.comments;
68
+ expect(comments).toHaveLength(2);
69
+ });
70
+ it('reports the empty-comments case', async () => {
71
+ stubJSON([{ json: { items: [], nextCursor: null } }]);
72
+ const res = await makeListDocCommentsHandler(client)({ docId: DOC_ID });
73
+ expect(res.content[0].text).toContain('No comments');
74
+ });
75
+ it('surfaces nextCursor in both the structured payload and the text', async () => {
76
+ stubJSON([{ json: { items: [ROOT_COMMENT], nextCursor: 'NEXT' } }]);
77
+ const res = await makeListDocCommentsHandler(client)({ docId: DOC_ID });
78
+ expect(res.content[0].text).toContain('NEXT');
79
+ expect(res.structuredContent).toMatchObject({ nextCursor: 'NEXT' });
80
+ });
81
+ it('surfaces the anchor preview when a comment is anchored', async () => {
82
+ const anchored = {
83
+ ...ROOT_COMMENT,
84
+ anchor: { text: 'retry backoff is 200ms', before: 'The ', after: '. Reset' },
85
+ };
86
+ stubJSON([{ json: { items: [anchored], nextCursor: null } }]);
87
+ const res = await makeListDocCommentsHandler(client)({ docId: DOC_ID });
88
+ expect(res.content[0].text).toContain('anchored on: "retry backoff is 200ms"');
89
+ });
90
+ });
91
+ describe('orboto_post_doc_comment', () => {
92
+ it('POSTs content with no parent for a root comment', async () => {
93
+ const calls = stubJSON([{ status: 201, json: ROOT_COMMENT }]);
94
+ await makePostDocCommentHandler(client)({
95
+ docId: DOC_ID,
96
+ content: 'Looks good to me.',
97
+ });
98
+ expect(calls[0]).toMatchObject({
99
+ method: 'POST',
100
+ url: `https://orboto.example.com/docs/${DOC_ID}/comments`,
101
+ body: { content: 'Looks good to me.' },
102
+ });
103
+ // No parentCommentId in the body for a root comment.
104
+ expect(calls[0].body.parentCommentId).toBeUndefined();
105
+ });
106
+ it('passes parentCommentId through for a reply', async () => {
107
+ const calls = stubJSON([{ status: 201, json: REPLY_COMMENT }]);
108
+ await makePostDocCommentHandler(client)({
109
+ docId: DOC_ID,
110
+ content: 'Agreed.',
111
+ parentCommentId: ROOT_COMMENT.id,
112
+ });
113
+ expect(calls[0].body).toMatchObject({
114
+ content: 'Agreed.',
115
+ parentCommentId: ROOT_COMMENT.id,
116
+ });
117
+ });
118
+ it('passes anchor through for an anchored comment', async () => {
119
+ const calls = stubJSON([{ status: 201, json: ROOT_COMMENT }]);
120
+ await makePostDocCommentHandler(client)({
121
+ docId: DOC_ID,
122
+ content: 'This needs clarification.',
123
+ anchor: { text: 'API key required', before: 'The ', after: '.' },
124
+ });
125
+ expect(calls[0].body.anchor).toMatchObject({
126
+ text: 'API key required',
127
+ before: 'The ',
128
+ after: '.',
129
+ });
130
+ });
131
+ });
132
+ describe('orboto_resolve_doc_comment', () => {
133
+ it('POSTs resolved=true on resolve', async () => {
134
+ const calls = stubJSON([{ json: { ...ROOT_COMMENT, resolvedAt: '2026-05-17T13:00:00.000Z', resolvedBy: 'u1' } }]);
135
+ const res = await makeResolveDocCommentHandler(client)({
136
+ docId: DOC_ID,
137
+ commentId: COMMENT_ID,
138
+ resolved: true,
139
+ });
140
+ expect(calls[0]).toMatchObject({
141
+ method: 'POST',
142
+ url: `https://orboto.example.com/docs/${DOC_ID}/comments/${COMMENT_ID}/resolve`,
143
+ body: { resolved: true },
144
+ });
145
+ expect(res.content[0].text).toContain('resolved');
146
+ });
147
+ it('POSTs resolved=false on reopen', async () => {
148
+ const calls = stubJSON([{ json: { ...ROOT_COMMENT, resolvedAt: null, resolvedBy: null } }]);
149
+ const res = await makeResolveDocCommentHandler(client)({
150
+ docId: DOC_ID,
151
+ commentId: COMMENT_ID,
152
+ resolved: false,
153
+ });
154
+ expect(calls[0].body).toMatchObject({ resolved: false });
155
+ expect(res.content[0].text).toContain('reopened');
156
+ });
157
+ });
158
+ describe('orboto_delete_doc_comment', () => {
159
+ it('DELETEs the comment id', async () => {
160
+ const calls = stubJSON([{ status: 204, json: undefined }]);
161
+ const res = await makeDeleteDocCommentHandler(client)({ docId: DOC_ID, commentId: COMMENT_ID });
162
+ expect(calls[0]).toMatchObject({
163
+ method: 'DELETE',
164
+ url: `https://orboto.example.com/docs/${DOC_ID}/comments/${COMMENT_ID}`,
165
+ });
166
+ expect(res.structuredContent).toMatchObject({ deleted: true });
167
+ });
168
+ it('bubbles up a 403 when deleting someone else\'s comment', async () => {
169
+ stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
170
+ await expect(makeDeleteDocCommentHandler(client)({ docId: DOC_ID, commentId: COMMENT_ID })).rejects.toBeInstanceOf(OrbotoApiError);
171
+ });
172
+ });
173
+ describe('orboto_update_doc_comment', () => {
174
+ it('PATCHes content on the comment id', async () => {
175
+ const calls = stubJSON([{ json: { ...ROOT_COMMENT, content: 'Updated.' } }]);
176
+ const res = await makeUpdateDocCommentHandler(client)({
177
+ docId: DOC_ID,
178
+ commentId: COMMENT_ID,
179
+ content: 'Updated.',
180
+ });
181
+ expect(calls[0]).toMatchObject({
182
+ method: 'PATCH',
183
+ url: `https://orboto.example.com/docs/${DOC_ID}/comments/${COMMENT_ID}`,
184
+ body: { content: 'Updated.' },
185
+ });
186
+ expect(res.structuredContent.content).toBe('Updated.');
187
+ });
188
+ it('bubbles up a 403 when editing someone else\'s comment', async () => {
189
+ stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden - only the author can edit this comment' } }]);
190
+ await expect(makeUpdateDocCommentHandler(client)({ docId: DOC_ID, commentId: COMMENT_ID, content: 'X' })).rejects.toBeInstanceOf(OrbotoApiError);
191
+ });
192
+ });