@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,219 @@
1
+ /**
2
+ * ORB-244 Phase D - MCP prompts.
3
+ *
4
+ * Prompts are reusable instruction templates an MCP-aware client
5
+ * (Claude Desktop, Cursor) surfaces in its UI as a one-click action.
6
+ * Each handler returns `messages[]` that become the start of the
7
+ * conversation; the user's model then drives the work, typically
8
+ * by calling orboto MCP tools.
9
+ *
10
+ * Five v1 templates - each a thin wrapper that hands the model a
11
+ * focused goal + a tool sequence to execute:
12
+ * - plan-sprint(projectKey) - draft a sprint plan from open work
13
+ * - triage-my-tickets() - sort caller's open assignments
14
+ * - summarize-project(projectKey) - quick project briefing
15
+ * - estimate-ticket(ticketKey) - rough effort guess via similar work
16
+ * - find-duplicates(ticketKey) - search for overlapping tickets
17
+ *
18
+ * No backend AI calls - the prompts steer the model, the model
19
+ * uses tools. Keeps the surface stateless and the AI provider
20
+ * irrelevant (works with any MCP client).
21
+ */
22
+ import { z } from 'zod';
23
+ export function registerOrbotoPrompts(server) {
24
+ // -------------------------------------------------------------------------
25
+ // plan-sprint
26
+ // -------------------------------------------------------------------------
27
+ server.registerPrompt('plan-sprint', {
28
+ title: 'Draft a sprint plan',
29
+ description: 'Build a 2-week sprint plan from a project\'s open backlog and the team\'s recent velocity.',
30
+ argsSchema: { projectKey: z.string().min(1).describe('Project key, e.g. "ACME".') },
31
+ }, ({ projectKey }) => ({
32
+ messages: [{
33
+ role: 'user',
34
+ content: {
35
+ type: 'text',
36
+ text: [
37
+ `Plan a 2-week sprint for project ${projectKey}.`,
38
+ '',
39
+ 'Steps:',
40
+ `1. Use \`orboto_get_project\` on "${projectKey}" to see milestones, members, and overall status.`,
41
+ `2. Use \`orboto_list_tickets\` with statusCategory="todo" to fetch the open backlog.`,
42
+ `3. Use \`orboto_list_milestones\` and pick the next not-yet-completed milestone as the sprint target.`,
43
+ '4. Sort the backlog by priority (blocker > high > normal > low > trivial), then by ticket age (older first), and propose 8-12 tickets that:',
44
+ ' - Together fit roughly the team\'s capacity (estimate via past closed tickets if visible)',
45
+ ' - Cover all the blockers and high-priority items',
46
+ ' - Have at most 1-2 epics so the sprint stays achievable',
47
+ '5. For each picked ticket, name the recommended assignee from the project members and a 1-line "why this person".',
48
+ '6. Output the plan as a Markdown table: ticket key, title, assignee, rough days. Then a 2-3 sentence summary on what this sprint aims to deliver.',
49
+ '',
50
+ 'If a step blocks (e.g. no milestones, empty backlog), say so plainly and stop instead of inventing data.',
51
+ ].join('\n'),
52
+ },
53
+ }],
54
+ }));
55
+ // -------------------------------------------------------------------------
56
+ // triage-my-tickets
57
+ // -------------------------------------------------------------------------
58
+ server.registerPrompt('triage-my-tickets', {
59
+ title: 'Triage my open tickets',
60
+ description: 'Sort the caller\'s open assignments and recommend the next 3 to focus on.',
61
+ argsSchema: {},
62
+ }, () => ({
63
+ messages: [{
64
+ role: 'user',
65
+ content: {
66
+ type: 'text',
67
+ text: [
68
+ 'Triage my open tickets.',
69
+ '',
70
+ 'Steps:',
71
+ '1. Use `orboto_my_tickets` (no statusCategory filter - defaults to open work) to fetch what I\'m assigned to.',
72
+ '2. For each ticket, fetch full context with `orboto_get_ticket` to see description, latest comment, blocking sub-tickets, and linked git activity.',
73
+ '3. Rank by:',
74
+ ' - Priority (blocker > high > normal > low)',
75
+ ' - Due date (overdue + soon-due first)',
76
+ ' - Whether something/someone is waiting (open PR comment, assignee comment asking a question)',
77
+ '4. Pick the top 3 and explain - for each - what the next concrete action is (write code? respond to a comment? close as won\'t fix?).',
78
+ '5. Surface anything that should be DELEGATED (assigned to someone else) or DROPPED (closed as won\'t fix), if anything fits.',
79
+ '',
80
+ 'Be terse. One paragraph per ticket. The point is to start working, not to read a thesis.',
81
+ ].join('\n'),
82
+ },
83
+ }],
84
+ }));
85
+ // -------------------------------------------------------------------------
86
+ // summarize-project
87
+ // -------------------------------------------------------------------------
88
+ server.registerPrompt('summarize-project', {
89
+ title: 'Summarise a project',
90
+ description: 'A 3-sentence briefing on a project - what it is, where it stands, what\'s next.',
91
+ argsSchema: { projectKey: z.string().min(1).describe('Project key, e.g. "ACME".') },
92
+ }, ({ projectKey }) => ({
93
+ messages: [{
94
+ role: 'user',
95
+ content: {
96
+ type: 'text',
97
+ text: [
98
+ `Give me a 3-sentence summary of project ${projectKey}.`,
99
+ '',
100
+ 'Steps:',
101
+ `1. Use \`orboto_get_project\` on "${projectKey}" to see metadata, milestones, members.`,
102
+ `2. Use \`orboto_list_tickets\` with statusCategory="in_progress" to see what's actively being worked.`,
103
+ `3. Use \`orboto_list_milestones\` to see what's nearest the deadline.`,
104
+ '',
105
+ 'Then write exactly 3 sentences: (a) what the project is, (b) where it currently stands, (c) what\'s next on the milestone path.',
106
+ 'No bullet lists, no headings. Just the three sentences.',
107
+ ].join('\n'),
108
+ },
109
+ }],
110
+ }));
111
+ // -------------------------------------------------------------------------
112
+ // estimate-ticket
113
+ // -------------------------------------------------------------------------
114
+ server.registerPrompt('estimate-ticket', {
115
+ title: 'Estimate a ticket\'s effort',
116
+ description: 'Rough effort guess for a ticket based on its description and similar past work.',
117
+ argsSchema: { ticketKey: z.string().min(3).describe('Ticket key, e.g. "ACME-42".') },
118
+ }, ({ ticketKey }) => ({
119
+ messages: [{
120
+ role: 'user',
121
+ content: {
122
+ type: 'text',
123
+ text: [
124
+ `Estimate the effort needed for ${ticketKey}.`,
125
+ '',
126
+ 'Steps:',
127
+ `1. Use \`orboto_get_ticket\` on "${ticketKey}" to read the description, type, priority, and any sub-tickets.`,
128
+ `2. Use \`orboto_search\` with the ticket's title and description to find 3-5 similar past tickets, ideally already closed.`,
129
+ '3. For each similar ticket found, use `orboto_get_ticket` to look up its loggedMinutes and check if the work seems comparable.',
130
+ '4. Compute a median + range from the comparable past efforts.',
131
+ '5. Output: estimated minutes, a confidence rating (low/medium/high), and one line per comparable ticket with its actual logged time.',
132
+ '',
133
+ 'If you can\'t find comparable past work, say so - don\'t guess from thin air.',
134
+ ].join('\n'),
135
+ },
136
+ }],
137
+ }));
138
+ // -------------------------------------------------------------------------
139
+ // find-duplicates
140
+ // -------------------------------------------------------------------------
141
+ server.registerPrompt('find-duplicates', {
142
+ title: 'Find duplicate tickets',
143
+ description: 'Look for tickets that overlap with the given one - likely duplicates or competing work.',
144
+ argsSchema: { ticketKey: z.string().min(3).describe('Ticket key, e.g. "ACME-42".') },
145
+ }, ({ ticketKey }) => ({
146
+ messages: [{
147
+ role: 'user',
148
+ content: {
149
+ type: 'text',
150
+ text: [
151
+ `Find tickets that might overlap with ${ticketKey}.`,
152
+ '',
153
+ 'Steps:',
154
+ `1. Use \`orboto_get_ticket\` on "${ticketKey}" to read its title and description.`,
155
+ '2. Pull 3-5 keyword phrases from the description.',
156
+ `3. Use \`orboto_search\` with each phrase to find candidate matches (limit each search to 5).`,
157
+ '4. Use `orboto_get_ticket` on the top 3 candidates to verify the actual overlap.',
158
+ '5. Output:',
159
+ ' - Likely duplicates (same intent, same scope) - list these first',
160
+ ' - Related but distinct (overlapping topic, different scope) - list these second',
161
+ ' - "Probably nothing" - only mention if all candidates were unrelated',
162
+ '',
163
+ 'Be honest: if the original ticket has too little detail to compare, say so and stop. False positives waste more time than they save.',
164
+ ].join('\n'),
165
+ },
166
+ }],
167
+ }));
168
+ // -------------------------------------------------------------------------
169
+ // ORB-855 - wiki-ingest: guided URL ingest flow.
170
+ // -------------------------------------------------------------------------
171
+ server.registerPrompt('wiki-ingest', {
172
+ title: 'Ingest a URL into the wiki',
173
+ description: 'Guided flow: import a URL into an LLM-Wiki space and confirm what the curation produced.',
174
+ argsSchema: { spaceId: z.string().min(1).describe('Target wiki space id.'), url: z.string().min(1).describe('Public URL to import.') },
175
+ }, ({ spaceId, url }) => ({
176
+ messages: [{
177
+ role: 'user',
178
+ content: {
179
+ type: 'text',
180
+ text: [
181
+ `Ingest ${url} into wiki space ${spaceId}.`,
182
+ '',
183
+ 'Steps:',
184
+ `1. Call \`orboto_wiki_ingest_url\` with spaceId="${spaceId}" and url="${url}".`,
185
+ '2. If the space runs review-gate, the curation lands as a pending plan - tell the operator to review it in the space (sidebar). If auto-apply, the pages were written directly.',
186
+ '3. Read `orboto://wiki/' + spaceId + '/log` to confirm what changed and summarise the new / updated pages in 2-3 sentences.',
187
+ '',
188
+ 'If ingest fails (bad URL, space not wiki-enabled), report the error plainly and stop.',
189
+ ].join('\n'),
190
+ },
191
+ }],
192
+ }));
193
+ // -------------------------------------------------------------------------
194
+ // ORB-855 - wiki-maintain: guided lint + fix flow.
195
+ // -------------------------------------------------------------------------
196
+ server.registerPrompt('wiki-maintain', {
197
+ title: 'Maintain a wiki (lint + fix)',
198
+ description: 'Run the lint pass on an LLM-Wiki space and propose fixes for the open issues.',
199
+ argsSchema: { spaceId: z.string().min(1).describe('Wiki space id to maintain.') },
200
+ }, ({ spaceId }) => ({
201
+ messages: [{
202
+ role: 'user',
203
+ content: {
204
+ type: 'text',
205
+ text: [
206
+ `Maintain wiki space ${spaceId}.`,
207
+ '',
208
+ 'Steps:',
209
+ `1. Call \`orboto_wiki_lint\` with spaceId="${spaceId}" to get the open issues.`,
210
+ '2. Group them by kind (orphans, missing cross-references, stale, contradictions, undocumented, unprocessed sources).',
211
+ '3. For each issue with a clear fix, propose the concrete change. For a fix the operator approves, use `orboto_wiki_plan_update` to draft it and `orboto_wiki_apply_plan` to commit - never apply destructive rewrites without confirmation.',
212
+ '4. Summarise what you fixed and what still needs a human decision.',
213
+ '',
214
+ 'If there are no open issues, say the wiki is clean and stop.',
215
+ ].join('\n'),
216
+ },
217
+ }],
218
+ }));
219
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ORB-244 Phase D - prompt template unit tests.
3
+ *
4
+ * Prompts are stateless: handler returns a `messages[]` shape based
5
+ * purely on input args. We register against a real McpServer and
6
+ * read back via the SDK's internal registry, then assert the
7
+ * returned message text mentions (a) the goal, (b) the tools the
8
+ * model is supposed to call.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { registerOrbotoPrompts } from './prompts.js';
13
+ function buildServerWithPrompts() {
14
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
15
+ registerOrbotoPrompts(server);
16
+ return server;
17
+ }
18
+ function getPromptHandler(server, name) {
19
+ const prompts = server._registeredPrompts;
20
+ const entry = prompts[name];
21
+ if (!entry)
22
+ throw new Error(`No prompt "${name}" - known: ${Object.keys(prompts).join(', ')}`);
23
+ return entry.callback;
24
+ }
25
+ describe('plan-sprint prompt', () => {
26
+ it('emits a single user message that names the project + tool sequence', () => {
27
+ const server = buildServerWithPrompts();
28
+ const handler = getPromptHandler(server, 'plan-sprint');
29
+ const out = handler({ projectKey: 'ACME' });
30
+ expect(out.messages).toHaveLength(1);
31
+ expect(out.messages[0].role).toBe('user');
32
+ const text = out.messages[0].content.text;
33
+ expect(text).toContain('project ACME');
34
+ expect(text).toContain('orboto_get_project');
35
+ expect(text).toContain('orboto_list_tickets');
36
+ expect(text).toContain('orboto_list_milestones');
37
+ });
38
+ });
39
+ describe('triage-my-tickets prompt', () => {
40
+ it('takes no args and instructs to call orboto_my_tickets', () => {
41
+ const server = buildServerWithPrompts();
42
+ const handler = getPromptHandler(server, 'triage-my-tickets');
43
+ const out = handler({});
44
+ const text = out.messages[0].content.text;
45
+ expect(text).toContain('orboto_my_tickets');
46
+ expect(text).toContain('top 3');
47
+ });
48
+ });
49
+ describe('summarize-project prompt', () => {
50
+ it('embeds the project key + asks for exactly 3 sentences', () => {
51
+ const server = buildServerWithPrompts();
52
+ const handler = getPromptHandler(server, 'summarize-project');
53
+ const out = handler({ projectKey: 'ACME' });
54
+ const text = out.messages[0].content.text;
55
+ expect(text).toContain('project ACME');
56
+ expect(text).toContain('exactly 3 sentences');
57
+ });
58
+ });
59
+ describe('estimate-ticket prompt', () => {
60
+ it('embeds the ticket key + asks for similar past tickets', () => {
61
+ const server = buildServerWithPrompts();
62
+ const handler = getPromptHandler(server, 'estimate-ticket');
63
+ const out = handler({ ticketKey: 'ACME-42' });
64
+ const text = out.messages[0].content.text;
65
+ expect(text).toContain('ACME-42');
66
+ expect(text).toContain('orboto_search');
67
+ expect(text).toContain('loggedMinutes');
68
+ });
69
+ });
70
+ describe('find-duplicates prompt', () => {
71
+ it('warns explicitly against false positives', () => {
72
+ const server = buildServerWithPrompts();
73
+ const handler = getPromptHandler(server, 'find-duplicates');
74
+ const out = handler({ ticketKey: 'ACME-7' });
75
+ const text = out.messages[0].content.text;
76
+ expect(text).toContain('ACME-7');
77
+ expect(text).toContain('False positives');
78
+ });
79
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * ORB-1818 - `PROTECT_TEXT_META` is a protection, not an escape hatch.
3
+ *
4
+ * A handler that sets it keeps its text half out of the shrinker, so a
5
+ * tool could use it to opt out of the central response budget - the one
6
+ * thing ORB-1697 exists to prevent. It is sanctioned for exactly one
7
+ * payload: the complete binding workspace rules, which must never be cut
8
+ * for either client class (Claude Code keeps the structured half,
9
+ * text-only clients keep the Markdown one).
10
+ *
11
+ * This ratchet fails the build when any other source file sets it, the
12
+ * same way the dark-mode / i18n / audit checks fail on a new violation.
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { join } from 'node:path';
18
+ const SRC = fileURLToPath(new URL('.', import.meta.url));
19
+ /** The files allowed to SET the flag (response-budget.ts defines and
20
+ * consumes it; the session-start tool is the sanctioned setter). */
21
+ const ALLOWED = new Set(['response-budget.ts', 'tools/session-start.ts']);
22
+ function walk(dir, prefix = '') {
23
+ return readdirSync(dir).flatMap((entry) => {
24
+ const full = join(dir, entry);
25
+ const rel = prefix ? `${prefix}/${entry}` : entry;
26
+ if (statSync(full).isDirectory())
27
+ return entry === 'node_modules' ? [] : walk(full, rel);
28
+ return rel.endsWith('.ts') ? [rel] : [];
29
+ });
30
+ }
31
+ describe('PROTECT_TEXT_META usage (ORB-1818)', () => {
32
+ it('is set by the session-start tool only', () => {
33
+ const offenders = walk(SRC)
34
+ .filter((rel) => !rel.endsWith('.test.ts') && !ALLOWED.has(rel))
35
+ .filter((rel) => readFileSync(join(SRC, rel), 'utf8').includes('PROTECT_TEXT_META'));
36
+ expect(offenders).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * ORB-244 Phase D - MCP resources.
3
+ *
4
+ * Resources let MCP-aware clients (Claude Desktop, Cursor) read
5
+ * orboto content as static blobs without explicitly invoking a tool.
6
+ * Where tools are RPCs ("do this thing"), resources are URIs ("here
7
+ * is content at this address").
8
+ *
9
+ * Four URI templates exposed:
10
+ * orboto://ticket/{ticketKey} - rendered Markdown of the ticket
11
+ * orboto://doc/{docId} - doc body (Markdown)
12
+ * orboto://project/{projectKey} - project summary
13
+ * orboto://search/{query} - search results as Markdown
14
+ *
15
+ * No `list` callback for tickets/docs because the candidate set is
16
+ * unbounded (every ticket, every doc) - the URI templates are
17
+ * sufficient. Clients discover content via tools first, then read a
18
+ * specific resource. The required `list: undefined` pattern keeps
19
+ * the SDK happy.
20
+ *
21
+ * Implementation note: each resource handler delegates to the same
22
+ * REST endpoints the tools use; the only difference is response
23
+ * shape (Markdown text vs. structured tool result). Sharing logic
24
+ * with tools/*.ts would couple the two surfaces too tightly - for
25
+ * now the resources are independent thin renderers.
26
+ */
27
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
28
+ import { OrbotoApiError } from './orboto-client.js';
29
+ import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
30
+ export function registerOrbotoResources(server, client) {
31
+ // -------------------------------------------------------------------------
32
+ // orboto://rules
33
+ //
34
+ // ORB-1177 - the COMPLETE assembled binding rules, cap-independent. The
35
+ // MCP `instructions` block is budgeted + may be truncated by the client
36
+ // (ORB-1168); this resource is never truncated, so a client can fetch
37
+ // the full rule set on demand. Same source orboto_session_start reads.
38
+ // -------------------------------------------------------------------------
39
+ server.registerResource('rules', new ResourceTemplate('orboto://rules', { list: undefined }), {
40
+ title: 'Workspace agent rules (complete)',
41
+ description: 'The complete, assembled binding rules you must follow as an agent in this workspace - cap-independent (the MCP instructions block may be truncated; this resource is not). orboto_session_start returns the same rules plus your in-progress work.',
42
+ mimeType: 'text/markdown',
43
+ }, async (uri) => {
44
+ const res = await client.get('/agent-instructions').catch(() => ({ instructions: '' }));
45
+ const rules = res?.instructions?.trim() || '(no workspace rules configured)';
46
+ return {
47
+ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# orboto workspace agent rules\n\n${rules}` }],
48
+ };
49
+ });
50
+ // -------------------------------------------------------------------------
51
+ // orboto://ticket/{ticketKey}
52
+ // -------------------------------------------------------------------------
53
+ server.registerResource('ticket', new ResourceTemplate('orboto://ticket/{ticketKey}', { list: undefined }), {
54
+ title: 'orboto ticket',
55
+ description: 'A single ticket with description, assignees, comments, and labels - rendered as Markdown.',
56
+ mimeType: 'text/markdown',
57
+ }, async (uri, vars) => {
58
+ const ticketKey = String(vars.ticketKey);
59
+ const ticket = await resolveTicketByKey(client, ticketKey);
60
+ const commentsPage = await client.get(`/tickets/${ticket.id}/comments?limit=50`).catch((err) => {
61
+ if (err instanceof OrbotoApiError && err.status === 404) {
62
+ return { items: [], nextCursor: null };
63
+ }
64
+ throw err;
65
+ });
66
+ return {
67
+ contents: [{
68
+ uri: uri.href,
69
+ mimeType: 'text/markdown',
70
+ text: renderTicketMarkdown(ticket, commentsPage.items),
71
+ }],
72
+ };
73
+ });
74
+ // -------------------------------------------------------------------------
75
+ // orboto://doc/{docId}
76
+ // -------------------------------------------------------------------------
77
+ server.registerResource('doc', new ResourceTemplate('orboto://doc/{docId}', { list: undefined }), {
78
+ title: 'orboto doc',
79
+ description: 'A wiki page from a doc space - Markdown body.',
80
+ mimeType: 'text/markdown',
81
+ }, async (uri, vars) => {
82
+ const docId = String(vars.docId);
83
+ const doc = await client.get(`/docs/${docId}`);
84
+ const lines = [
85
+ `# ${doc.title}`,
86
+ `_Visibility: ${doc.visibility} · Updated: ${doc.updatedAt}_`,
87
+ '',
88
+ doc.content || '_(empty)_',
89
+ ];
90
+ return {
91
+ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: lines.join('\n') }],
92
+ };
93
+ });
94
+ // -------------------------------------------------------------------------
95
+ // orboto://project/{projectKey}
96
+ // -------------------------------------------------------------------------
97
+ server.registerResource('project', new ResourceTemplate('orboto://project/{projectKey}', { list: undefined }), {
98
+ title: 'orboto project',
99
+ description: 'Project metadata, milestones, members.',
100
+ mimeType: 'text/markdown',
101
+ }, async (uri, vars) => {
102
+ const projectKey = String(vars.projectKey);
103
+ const project = await resolveProjectByKey(client, projectKey);
104
+ const [milestones, members] = await Promise.all([
105
+ client.get(`/projects/${project.id}/milestones`),
106
+ client.get(`/projects/${project.id}/members`),
107
+ ]);
108
+ return {
109
+ contents: [{
110
+ uri: uri.href,
111
+ mimeType: 'text/markdown',
112
+ text: renderProjectMarkdown(project, milestones, members),
113
+ }],
114
+ };
115
+ });
116
+ // -------------------------------------------------------------------------
117
+ // orboto://search/{query}
118
+ //
119
+ // Note: clients URL-encode `query` automatically. Special chars in
120
+ // a natural-language query are fine; the URI template handles
121
+ // unescaping.
122
+ // -------------------------------------------------------------------------
123
+ server.registerResource('search', new ResourceTemplate('orboto://search/{query}', { list: undefined }), {
124
+ title: 'orboto search',
125
+ description: 'Full-text search across tickets, comments, docs - visibility-filtered.',
126
+ mimeType: 'text/markdown',
127
+ }, async (uri, vars) => {
128
+ const query = String(vars.query);
129
+ const qs = new URLSearchParams({ q: query, limit: '15' });
130
+ const res = await client.get(`/search?${qs}`);
131
+ const lines = res.items.length === 0
132
+ ? [`# Search: ${query}`, '', '_No hits._']
133
+ : [
134
+ `# Search: ${query}`,
135
+ `_${res.total} total hit(s) - top ${res.items.length} shown._`,
136
+ '',
137
+ ...res.items.map((h) => {
138
+ const tag = h.type.toUpperCase();
139
+ const ident = h.ticketKey ?? h.url;
140
+ const project = h.projectName ? ` (${h.projectName})` : '';
141
+ return `- **${tag} ${ident}**${project}: ${h.title}\n ${h.excerpt}`;
142
+ }),
143
+ ];
144
+ return {
145
+ contents: [{
146
+ uri: uri.href,
147
+ mimeType: 'text/markdown',
148
+ text: lines.join('\n'),
149
+ }],
150
+ };
151
+ });
152
+ // -------------------------------------------------------------------------
153
+ // orboto://user/me/notifications
154
+ //
155
+ // ORB-706 - read the calling user's recent notifications.
156
+ // Subscribable: every `notification:new` event for this user
157
+ // fires a `resources/updated` push. Used by agents that want to
158
+ // react to mentions / agent_message / status-change pings in
159
+ // real time.
160
+ // -------------------------------------------------------------------------
161
+ server.registerResource('user-notifications', new ResourceTemplate('orboto://user/me/notifications', { list: undefined }), {
162
+ title: 'My notifications',
163
+ description: 'Calling user\'s recent notifications. Subscribable - every new notification fires resources/updated. Use this to react to mentions, agent_message (ORB-705), and status-change pings in real time.',
164
+ mimeType: 'text/markdown',
165
+ }, async (uri) => {
166
+ const page = await client.get('/notifications?limit=20');
167
+ const lines = [];
168
+ lines.push(`# My notifications`);
169
+ lines.push(`_${page.unreadCount} unread of ${page.items.length} shown._`);
170
+ lines.push('');
171
+ if (page.items.length === 0) {
172
+ lines.push('_No notifications._');
173
+ }
174
+ else {
175
+ for (const n of page.items) {
176
+ const unread = n.readAt ? '' : ' **unread**';
177
+ const subject = typeof n.payload.subject === 'string'
178
+ ? n.payload.subject
179
+ : typeof n.payload.message === 'string' ? n.payload.message : n.type;
180
+ lines.push(`- [${n.type}]${unread} - ${subject} _(${n.createdAt})_`);
181
+ }
182
+ }
183
+ return {
184
+ contents: [{
185
+ uri: uri.href,
186
+ mimeType: 'text/markdown',
187
+ text: lines.join('\n'),
188
+ }],
189
+ };
190
+ });
191
+ // -------------------------------------------------------------------------
192
+ // ORB-855 - LLM-Wiki resources. The index + log are the space's singleton
193
+ // kind='index'/'log' docs; a page is any doc by id (namespaced under the
194
+ // space). A `page/` discriminator keeps the page template from colliding
195
+ // with the static index/log segments.
196
+ // -------------------------------------------------------------------------
197
+ const layerDoc = async (spaceId, kind) => {
198
+ const list = await client.get(`/spaces/${spaceId}/docs`);
199
+ return list.find((d) => d.kind === kind) ?? null;
200
+ };
201
+ server.registerResource('wiki-index', new ResourceTemplate('orboto://wiki/{spaceId}/index', { list: undefined }), { title: 'LLM-Wiki index', description: 'The auto-maintained navigation index of an LLM-Wiki space.', mimeType: 'text/markdown' }, async (uri, vars) => {
202
+ const doc = await layerDoc(String(vars.spaceId), 'index');
203
+ return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: doc?.content || '_(no index)_' }] };
204
+ });
205
+ server.registerResource('wiki-log', new ResourceTemplate('orboto://wiki/{spaceId}/log', { list: undefined }), { title: 'LLM-Wiki activity log', description: 'The append-only activity log of an LLM-Wiki space.', mimeType: 'text/markdown' }, async (uri, vars) => {
206
+ const doc = await layerDoc(String(vars.spaceId), 'log');
207
+ return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: doc?.content || '_(no log)_' }] };
208
+ });
209
+ server.registerResource('wiki-page', new ResourceTemplate('orboto://wiki/{spaceId}/page/{docId}', { list: undefined }), { title: 'LLM-Wiki page', description: 'A single wiki page by id within an LLM-Wiki space.', mimeType: 'text/markdown' }, async (uri, vars) => {
210
+ const doc = await client.get(`/docs/${String(vars.docId)}`);
211
+ return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# ${doc.title}\n\n${doc.content || '_(empty)_'}` }] };
212
+ });
213
+ }
214
+ function renderTicketMarkdown(ticket, comments) {
215
+ const lines = [];
216
+ lines.push(`# [${ticket.ticketKey}] ${ticket.title}`);
217
+ lines.push(`_Status: ${ticket.statusName ?? ticket.status} · Priority: ${ticket.priority} · Type: ${ticket.type}_`);
218
+ if (ticket.dueDate)
219
+ lines.push(`_Due: ${ticket.dueDate}_`);
220
+ if (ticket.assignees && ticket.assignees.length > 0) {
221
+ lines.push(`_Assignees: ${ticket.assignees.map((a) => a.fullName || a.email).join(', ')}_`);
222
+ }
223
+ if (ticket.labels && ticket.labels.length > 0) {
224
+ lines.push(`_Labels: ${ticket.labels.map((l) => l.name).join(', ')}_`);
225
+ }
226
+ if (ticket.description) {
227
+ lines.push('', '## Description', ticket.description);
228
+ }
229
+ if (comments.length > 0) {
230
+ lines.push('', `## Comments (${comments.length})`);
231
+ for (const c of comments) {
232
+ lines.push('', `**${c.userName ?? '(unknown)'}** - ${c.createdAt}${c.isInternal ? ' [internal]' : ''}`);
233
+ lines.push(c.content);
234
+ }
235
+ }
236
+ return lines.join('\n');
237
+ }
238
+ function renderProjectMarkdown(project, milestones, members) {
239
+ const lines = [
240
+ `# ${project.key} - ${project.name}`,
241
+ `_Status: ${project.status}_`,
242
+ ];
243
+ if (project.description)
244
+ lines.push('', project.description);
245
+ if (milestones.length > 0) {
246
+ lines.push('', '## Milestones');
247
+ for (const m of milestones) {
248
+ const range = [m.startDate, m.endDate].filter(Boolean).join(' → ') || 'no dates';
249
+ lines.push(`- **${m.name}** [${m.status}] (${range})`);
250
+ }
251
+ }
252
+ if (members.length > 0) {
253
+ lines.push('', '## Members');
254
+ for (const m of members) {
255
+ const name = m.user.fullName || m.user.email;
256
+ lines.push(`- ${name} <${m.user.email}> - ${m.role.name}`);
257
+ }
258
+ }
259
+ return lines.join('\n');
260
+ }