@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,81 @@
1
+ /** The tool whose whole job is to load the rules - never nudge on it. */
2
+ export const SESSION_START_TOOL = 'orboto_session_start';
3
+ /**
4
+ * The one-time reminder. English, ASCII-only, no em/en-dashes so it
5
+ * survives every client. Prepended as a leading text block only -
6
+ * structuredContent and the tool's own content are left untouched.
7
+ */
8
+ export const SESSION_START_NUDGE = 'NOTE: you have not loaded this workspace\'s binding operating rules yet. ' +
9
+ 'Call `orboto_session_start` now - it returns the rules you must follow ' +
10
+ 'plus your in-progress work.';
11
+ /** Fresh per-session (HTTP) / per-process (stdio) nudge state. `gateEnabled`
12
+ * comes from the workspace config fetched at server build (default off). */
13
+ export function createNudgeState(gateEnabled = false) {
14
+ return { firstToolCallSeen: false, sessionStartRan: false, gateEnabled };
15
+ }
16
+ /**
17
+ * Advance the state for one dispatch and report whether this dispatch
18
+ * should carry the nudge. True ONLY for the first tool call of the
19
+ * session when that call is not `orboto_session_start`. Every later call
20
+ * - and the session_start-first case - returns false. Idempotent after
21
+ * the first call because the flag is already set.
22
+ */
23
+ export function shouldNudge(state, toolName) {
24
+ if (state.firstToolCallSeen)
25
+ return false;
26
+ state.firstToolCallSeen = true;
27
+ return toolName !== SESSION_START_TOOL;
28
+ }
29
+ /**
30
+ * Return a copy of the result with the nudge prepended as a leading text
31
+ * block. Does not mutate the input, does not touch `structuredContent`,
32
+ * and preserves `isError` and every existing content block.
33
+ */
34
+ export function prependNudge(result) {
35
+ return {
36
+ ...result,
37
+ content: [
38
+ { type: 'text', text: SESSION_START_NUDGE },
39
+ ...(result.content ?? []),
40
+ ],
41
+ };
42
+ }
43
+ /**
44
+ * ORB-1471 - the HARD session-start gate message. Unlike the soft nudge
45
+ * (which rides along on the tool's real result), the gate REFUSES the tool
46
+ * call outright and returns this instead. English, ASCII-only.
47
+ */
48
+ export const SESSION_START_GATE_MESSAGE = 'This workspace requires you to load its binding operating rules before any ' +
49
+ 'other action. Call `orboto_session_start` now - it returns the rules you ' +
50
+ 'must follow plus your in-progress work - then retry this call.';
51
+ /**
52
+ * ORB-1471 - advance the gate state for one dispatch and report whether this
53
+ * dispatch must be REFUSED (returned an instructive error without running the
54
+ * handler).
55
+ *
56
+ * Semantics when the gate is enabled: every tool call other than
57
+ * `orboto_session_start` is refused until `orboto_session_start` has run once
58
+ * this session. Calling `orboto_session_start` marks the session unlocked
59
+ * (and is itself never gated). When the gate is disabled, nothing is ever
60
+ * refused (returns false) - default behaviour is unchanged.
61
+ *
62
+ * Idempotent bookkeeping: running `orboto_session_start` flips
63
+ * `sessionStartRan` so all later calls pass.
64
+ */
65
+ export function shouldGate(state, toolName) {
66
+ if (toolName === SESSION_START_TOOL) {
67
+ // The rule-loading tool always runs, and running it unlocks the session.
68
+ state.sessionStartRan = true;
69
+ return false;
70
+ }
71
+ if (!state.gateEnabled)
72
+ return false;
73
+ return !state.sessionStartRan;
74
+ }
75
+ /** ORB-1471 - the instructive refusal result the gate returns. */
76
+ export function gateResult() {
77
+ return {
78
+ isError: true,
79
+ content: [{ type: 'text', text: SESSION_START_GATE_MESSAGE }],
80
+ };
81
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ORB-1331 - session-start nudge state machine + prepend helper.
3
+ *
4
+ * Unit-level proof of the semantics the transport tests then exercise
5
+ * end-to-end: fire once, only on the first tool call, never when that
6
+ * call is orboto_session_start, and never mutate structuredContent.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, gateResult, prependNudge, } from './session-nudge.js';
10
+ describe('shouldNudge (ORB-1331)', () => {
11
+ it('nudges on the first tool call when it is not session_start, then never again', () => {
12
+ const state = createNudgeState();
13
+ expect(shouldNudge(state, 'orboto_list_projects')).toBe(true);
14
+ // Every subsequent call is clean regardless of tool.
15
+ expect(shouldNudge(state, 'orboto_get_ticket')).toBe(false);
16
+ expect(shouldNudge(state, 'orboto_list_projects')).toBe(false);
17
+ expect(shouldNudge(state, SESSION_START_TOOL)).toBe(false);
18
+ });
19
+ it('never nudges when the first call IS session_start', () => {
20
+ const state = createNudgeState();
21
+ expect(shouldNudge(state, SESSION_START_TOOL)).toBe(false);
22
+ // and does not resurface on later calls either.
23
+ expect(shouldNudge(state, 'orboto_list_projects')).toBe(false);
24
+ });
25
+ it('isolates state between sessions/processes (each fresh state nudges once)', () => {
26
+ const a = createNudgeState();
27
+ const b = createNudgeState();
28
+ expect(shouldNudge(a, 'orboto_list_projects')).toBe(true);
29
+ // b is untouched by a - its own first call still nudges.
30
+ expect(shouldNudge(b, 'orboto_list_projects')).toBe(true);
31
+ expect(shouldNudge(a, 'orboto_list_projects')).toBe(false);
32
+ expect(shouldNudge(b, 'orboto_list_projects')).toBe(false);
33
+ });
34
+ });
35
+ describe('shouldGate (ORB-1471)', () => {
36
+ it('never gates when the gate is disabled (default), whatever the tool', () => {
37
+ const state = createNudgeState(); // gateEnabled defaults false
38
+ expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
39
+ expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
40
+ expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
41
+ });
42
+ it('when enabled, refuses every non-session-start tool until session_start runs', () => {
43
+ const state = createNudgeState(true);
44
+ // Pre session_start: every other tool is gated.
45
+ expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
46
+ expect(shouldGate(state, 'orboto_create_ticket')).toBe(true);
47
+ // session_start itself is never gated AND unlocks the session.
48
+ expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
49
+ // Post session_start: everything passes.
50
+ expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
51
+ expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
52
+ });
53
+ it('session_start as the very first call unlocks immediately (one-call cost avoided)', () => {
54
+ const state = createNudgeState(true);
55
+ expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
56
+ expect(shouldGate(state, 'orboto_get_ticket')).toBe(false);
57
+ });
58
+ it('gateResult is an instructive isError result pointing at session_start', () => {
59
+ const r = gateResult();
60
+ expect(r.isError).toBe(true);
61
+ expect(r.content[0]).toEqual({ type: 'text', text: SESSION_START_GATE_MESSAGE });
62
+ });
63
+ it('gate message is English, ASCII-only, and free of em/en-dashes', () => {
64
+ // eslint-disable-next-line no-control-regex
65
+ expect(/^[\x00-\x7F]*$/.test(SESSION_START_GATE_MESSAGE)).toBe(true);
66
+ expect(SESSION_START_GATE_MESSAGE).not.toMatch(/[\u2013\u2014]/);
67
+ expect(SESSION_START_GATE_MESSAGE).toContain('orboto_session_start');
68
+ });
69
+ });
70
+ describe('SESSION_START_NUDGE text (ORB-1331)', () => {
71
+ it('is English, ASCII-only, and free of em/en-dashes', () => {
72
+ // eslint-disable-next-line no-control-regex
73
+ expect(/^[\x00-\x7F]*$/.test(SESSION_START_NUDGE)).toBe(true);
74
+ expect(SESSION_START_NUDGE).not.toMatch(/[\u2013\u2014]/); // en/em dash
75
+ expect(SESSION_START_NUDGE).toContain('orboto_session_start');
76
+ });
77
+ });
78
+ describe('prependNudge (ORB-1331)', () => {
79
+ it('prepends the nudge as a leading text block without touching structuredContent or isError', () => {
80
+ const original = {
81
+ content: [{ type: 'text', text: 'tool output' }],
82
+ structuredContent: { projects: [], total: 0 },
83
+ };
84
+ const out = prependNudge(original);
85
+ expect(out.content).toHaveLength(2);
86
+ expect(out.content[0]).toEqual({ type: 'text', text: SESSION_START_NUDGE });
87
+ expect(out.content[1]).toEqual({ type: 'text', text: 'tool output' });
88
+ // structuredContent consumers are unaffected - same reference-equal payload.
89
+ expect(out.structuredContent).toBe(original.structuredContent);
90
+ // input is not mutated.
91
+ expect(original.content).toHaveLength(1);
92
+ });
93
+ it('preserves isError on an error result', () => {
94
+ const errResult = { isError: true, content: [{ type: 'text', text: 'boom' }] };
95
+ const out = prependNudge(errResult);
96
+ expect(out.isError).toBe(true);
97
+ expect(out.content[0]).toEqual({ type: 'text', text: SESSION_START_NUDGE });
98
+ expect(out.content[1]).toEqual({ type: 'text', text: 'boom' });
99
+ });
100
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ORB-1331 - end-to-end transport proof of the session-start nudge.
3
+ *
4
+ * Drives the REAL server (buildOrbotoMcpServer → registerWithMetrics →
5
+ * withMetrics → prependNudge) over an in-memory MCP transport with a
6
+ * genuine MCP Client, so the whole JSON-RPC dispatch path is exercised,
7
+ * not just the helper in isolation.
8
+ *
9
+ * Transport semantics both reduce to the lifetime of the per-build nudge
10
+ * state:
11
+ * - HTTP: the transport calls buildOrbotoMcpServer once PER SESSION, so
12
+ * each session is an independent server instance with its own state.
13
+ * - stdio: buildOrbotoMcpServer is called once PER PROCESS, so a single
14
+ * server instance's state is process-local.
15
+ * We model a "session"/"process" as one built server + one connected
16
+ * client, and assert the nudge fires exactly once on the first non-
17
+ * session_start call and never when the first call is session_start.
18
+ */
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
21
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
22
+ import { buildOrbotoMcpServer } from './server.js';
23
+ import { SESSION_START_NUDGE } from './session-nudge.js';
24
+ // Every API call the tools make resolves to a benign empty payload so
25
+ // the handlers succeed without a live backend. list_projects → [] →
26
+ // "no projects" text + valid structuredContent; session_start's sub-
27
+ // calls are all .catch-guarded so [] is harmless.
28
+ function mockApi() {
29
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
30
+ ok: true,
31
+ status: 200,
32
+ statusText: 'OK',
33
+ headers: new Headers({ 'content-type': 'application/json' }),
34
+ json: async () => [],
35
+ text: async () => '[]',
36
+ }));
37
+ }
38
+ beforeEach(() => { vi.restoreAllMocks(); mockApi(); });
39
+ afterEach(() => { vi.restoreAllMocks(); });
40
+ /** Build a fresh server ("session"/"process") + a connected MCP client. */
41
+ async function connectSession() {
42
+ const server = await buildOrbotoMcpServer({ baseUrl: 'http://api.invalid', apiKey: 'orb_test' });
43
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
44
+ const client = new Client({ name: 'test-client', version: '0.0.0' });
45
+ await Promise.all([
46
+ server.connect(serverTransport),
47
+ client.connect(clientTransport),
48
+ ]);
49
+ return client;
50
+ }
51
+ /** First text block of a tool result. */
52
+ function firstText(res) {
53
+ const content = res.content;
54
+ return content[0]?.text ?? '';
55
+ }
56
+ describe('ORB-1331 - HTTP transport (per-session nudge)', () => {
57
+ it('prepends the nudge exactly once when the first call is orboto_list_projects; the second call is clean', async () => {
58
+ const client = await connectSession();
59
+ const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
60
+ expect(firstText(first)).toContain(SESSION_START_NUDGE);
61
+ // structuredContent must be untouched by the text-block prepend.
62
+ expect(first.structuredContent).toMatchObject({ projects: [], total: 0 });
63
+ const second = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
64
+ expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
65
+ });
66
+ it('never nudges a session whose first call is orboto_session_start', async () => {
67
+ const client = await connectSession();
68
+ const first = await client.callTool({ name: 'orboto_session_start', arguments: {} });
69
+ expect(firstText(first)).not.toContain(SESSION_START_NUDGE);
70
+ const second = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
71
+ expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
72
+ });
73
+ it('isolates the nudge per session - a second, independent session nudges on its own first call', async () => {
74
+ const a = await connectSession();
75
+ const b = await connectSession();
76
+ const aFirst = await a.callTool({ name: 'orboto_list_projects', arguments: {} });
77
+ expect(firstText(aFirst)).toContain(SESSION_START_NUDGE);
78
+ // b is a distinct built server → distinct state → still nudges.
79
+ const bFirst = await b.callTool({ name: 'orboto_list_projects', arguments: {} });
80
+ expect(firstText(bFirst)).toContain(SESSION_START_NUDGE);
81
+ });
82
+ });
83
+ describe('ORB-1331 - stdio transport (process-local nudge)', () => {
84
+ it('fires once for the process: first non-session_start call nudged, all later calls clean', async () => {
85
+ // stdio builds one server per process → one nudge state reused for
86
+ // every call on this single connected client.
87
+ const client = await connectSession();
88
+ const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
89
+ expect(firstText(first)).toContain(SESSION_START_NUDGE);
90
+ const second = await client.callTool({ name: 'orboto_session_start', arguments: {} });
91
+ expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
92
+ const third = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
93
+ expect(firstText(third)).not.toContain(SESSION_START_NUDGE);
94
+ });
95
+ it('a process whose first call is session_start never sees the nudge', async () => {
96
+ const client = await connectSession();
97
+ const first = await client.callTool({ name: 'orboto_session_start', arguments: {} });
98
+ expect(firstText(first)).not.toContain(SESSION_START_NUDGE);
99
+ });
100
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ORB-1669 - safety-annotation ratchet for the MCP tool surface.
3
+ *
4
+ * MCP clients use `annotations` to decide whether a tool call needs a
5
+ * confirmation step. The hints DEFAULT badly for us: per the MCP spec a
6
+ * tool with no annotations is read-only=false / destructive=true /
7
+ * idempotent=false - so an unannotated read looks dangerous, and (worse,
8
+ * because it is the silent direction) a write annotated only with
9
+ * `{ readOnlyHint: false }` still reports destructive=true. Before this
10
+ * ticket 90 of 168 tools carried nothing at all and `orboto_bulk_close`
11
+ * was indistinguishable from `orboto_get_ticket`.
12
+ *
13
+ * This test enumerates the tools exactly as a client sees them -
14
+ * `tools/list` over an in-memory transport, not a source scan - and
15
+ * fails the build on a new tool that skips its annotations. There is no
16
+ * allowlist on purpose: unlike the dark-mode / i18n ratchets there is no
17
+ * legitimate "sanctioned exception", every tool can answer these
18
+ * questions.
19
+ *
20
+ * THE CONVENTION (apply it when you add a tool):
21
+ *
22
+ * readOnlyHint: true - performs no writes at all.
23
+ * destructiveHint: true - the call can DELETE or REMOVE a row, DISCARD
24
+ * content, take over state owned by someone
25
+ * else, or mutate many tickets at once. If ANY
26
+ * argument combination can do that, the tool is
27
+ * destructive - the hint is per-tool and cannot
28
+ * be conditioned on a flag (this is why
29
+ * `orboto_claim` is destructive: `sole=true`
30
+ * strips every other assignee).
31
+ * destructiveHint: false- purely additive, or a reversible single-entity
32
+ * field/state update.
33
+ * idempotentHint: true - repeating the call with identical arguments
34
+ * has no further effect.
35
+ */
36
+ import { describe, it, expect, beforeAll } from 'vitest';
37
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
38
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
39
+ import { buildOrbotoMcpServer } from './server.js';
40
+ /** Anthropic's connector review caps the human-readable tool title. It is
41
+ * also just good UI hygiene - a title that long is a description. */
42
+ const TITLE_MAX = 64;
43
+ let tools;
44
+ beforeAll(async () => {
45
+ // Port 1 is never listened on -> the connect-time /agent-instructions
46
+ // fetch fails immediately and the server falls back to its built-in
47
+ // rules. No network, no fixture server, no timeout.
48
+ // ORB-1520 made the CURATED manifest the default; the annotation contract
49
+ // covers every registered tool, so this ratchet lists the FULL toolset.
50
+ const server = await buildOrbotoMcpServer({ baseUrl: 'http://127.0.0.1:1', apiKey: 'orb_test', toolset: 'full' });
51
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
52
+ const client = new Client({ name: 'annotation-ratchet', version: '0' });
53
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
54
+ tools = (await client.listTools()).tools;
55
+ await client.close();
56
+ });
57
+ describe('MCP tool safety annotations (ORB-1669)', () => {
58
+ it('enumerates the full tool surface', () => {
59
+ // Guards the guard: if the in-memory handshake ever silently returns
60
+ // nothing, every expectation below would vacuously pass.
61
+ expect(tools.length).toBeGreaterThan(150);
62
+ });
63
+ it('every tool declares annotations with an explicit readOnlyHint', () => {
64
+ const offenders = tools
65
+ .filter((t) => typeof t.annotations?.readOnlyHint !== 'boolean')
66
+ .map((t) => t.name);
67
+ expect(offenders, `Tools missing an explicit \`readOnlyHint\`: ${offenders.join(', ')}.\n` +
68
+ 'Add an `annotations` block to the tool config in apps/mcp/src/tools/ - see the convention at the top of this file.').toEqual([]);
69
+ });
70
+ it('every write tool declares an explicit destructiveHint', () => {
71
+ // The load-bearing one. `{ readOnlyHint: false }` alone leaves
72
+ // destructiveHint defaulting to TRUE, so a harmless write advertises
73
+ // itself as destructive and the signal stops meaning anything.
74
+ const offenders = tools
75
+ .filter((t) => t.annotations?.readOnlyHint === false)
76
+ .filter((t) => typeof t.annotations?.destructiveHint !== 'boolean')
77
+ .map((t) => t.name);
78
+ expect(offenders, `Write tools missing an explicit \`destructiveHint\`: ${offenders.join(', ')}.\n` +
79
+ 'A write with no destructiveHint is reported to clients as destructive (spec default). Set it deliberately, true or false.').toEqual([]);
80
+ });
81
+ it('read-only tools never claim to be destructive', () => {
82
+ const offenders = tools
83
+ .filter((t) => t.annotations?.readOnlyHint === true && t.annotations?.destructiveHint === true)
84
+ .map((t) => t.name);
85
+ expect(offenders, `Contradictory annotations on: ${offenders.join(', ')}`).toEqual([]);
86
+ });
87
+ it(`every tool has a human-readable title of at most ${TITLE_MAX} characters`, () => {
88
+ const missing = tools.filter((t) => !(t.title ?? t.annotations?.title)).map((t) => t.name);
89
+ expect(missing, `Tools with no title: ${missing.join(', ')}`).toEqual([]);
90
+ const tooLong = tools
91
+ .map((t) => ({ name: t.name, title: (t.title ?? t.annotations?.title) }))
92
+ .filter((t) => t.title.length > TITLE_MAX)
93
+ .map((t) => `${t.name} (${t.title.length}: "${t.title}")`);
94
+ expect(tooLong, `Titles over ${TITLE_MAX} characters: ${tooLong.join('; ')}.\n` +
95
+ 'Shorten the `title` - the long form belongs in `description`.').toEqual([]);
96
+ });
97
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ORB-1741 - the manifest diet (epic ORB-1691).
3
+ *
4
+ * Every tool description in the manifest is STANDING context cost: an
5
+ * eager-loading client pays it on connect, every session. The curated
6
+ * toolset (ORB-1520) cut the tool COUNT; this module cuts the per-tool
7
+ * TEXT. At registration time (with-metrics.ts) the full description is
8
+ * captured here and the wire manifest carries only a one-sentence
9
+ * summary; `orboto_help { tool }` serves the full guidance lazily - the
10
+ * same deferred-docs pattern ToolSearch uses for deferred tools.
11
+ *
12
+ * The full texts stay where they always lived - in each tool file's
13
+ * `description` - so authors keep writing complete guidance and nothing
14
+ * is lost: the summary is DERIVED (first sentence, or a hand-written
15
+ * override for the few whose first sentence overruns the cap).
16
+ */
17
+ /** Soft cap for a wire description; the ratchet in manifest-size.test.ts
18
+ * enforces the aggregate outcome. */
19
+ export const SUMMARY_MAX_CHARS = 220;
20
+ /**
21
+ * Hand-written one-liners for tools whose first sentence exceeds the cap
22
+ * (measured 2026-08-28: 6 of 172). Keep each under SUMMARY_MAX_CHARS and
23
+ * name the key inputs.
24
+ */
25
+ const SUMMARY_OVERRIDES = {
26
+ orboto_create_full_backup: 'Start a full-workspace backup job (all projects, users, config, attachments); returns the job id to poll via orboto_list_backups.',
27
+ orboto_get_ticket: 'Fetch one ticket by key (ORB-42) or UUID: full detail incl. description, status, assignees, checklists, dependencies and comments.',
28
+ orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets (scope, actors, functional + non-functional requirements).',
29
+ orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
30
+ orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
31
+ orboto_critical_path: 'Compute the dependency-based critical path for a project or milestone: the blocking chain of tickets that determines the earliest finish.',
32
+ };
33
+ /** Runtime registry: tool name -> full guidance text, captured at
34
+ * registration. Module-global on purpose - the HTTP transport builds one
35
+ * server per session but the docs are identical, so re-capture is an
36
+ * idempotent Map.set. */
37
+ const toolDocs = new Map();
38
+ export function captureToolDoc(toolName, fullDescription) {
39
+ toolDocs.set(toolName, fullDescription);
40
+ }
41
+ export function getToolDoc(toolName) {
42
+ return toolDocs.get(toolName);
43
+ }
44
+ export function listToolDocNames() {
45
+ return [...toolDocs.keys()].sort();
46
+ }
47
+ /**
48
+ * One-sentence wire summary: the override when one exists, else the
49
+ * first sentence (sentence-end followed by whitespace/EOL). A first
50
+ * sentence still over the cap falls back to a word-boundary cut - the
51
+ * summary must never silently exceed what the ratchet budgets for.
52
+ */
53
+ export function summarizeToolDescription(toolName, full) {
54
+ const override = SUMMARY_OVERRIDES[toolName];
55
+ if (override)
56
+ return override;
57
+ const match = full.match(/^[\s\S]*?[.!?](?=\s|$)/);
58
+ let first = (match ? match[0] : full).trim();
59
+ if (first.length > SUMMARY_MAX_CHARS) {
60
+ const cut = first.slice(0, SUMMARY_MAX_CHARS - 3);
61
+ first = `${cut.slice(0, cut.lastIndexOf(' '))}...`;
62
+ }
63
+ return first;
64
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ORB-1741 - manifest diet: one-line wire descriptions + lazy full docs.
3
+ *
4
+ * Pins (1) the summarizer's three paths (override, first sentence,
5
+ * word-boundary fallback), (2) that the REAL manifest ships only
6
+ * summaries while orboto_help returns the full captured guidance for
7
+ * every registered tool - the "no guidance lost" acceptance criterion,
8
+ * verified against the live registration path, not fixtures.
9
+ */
10
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
11
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
12
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
13
+ import { buildOrbotoMcpServer } from './server.js';
14
+ import { summarizeToolDescription, captureToolDoc, getToolDoc, SUMMARY_MAX_CHARS, } from './tool-docs.js';
15
+ import { makeHelpHandler } from './tools/help.js';
16
+ beforeEach(() => {
17
+ vi.restoreAllMocks();
18
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
19
+ delete process.env.ORBOTO_MCP_TOOLSET;
20
+ });
21
+ afterEach(() => { vi.restoreAllMocks(); });
22
+ describe('summarizeToolDescription', () => {
23
+ it('takes the first sentence', () => {
24
+ expect(summarizeToolDescription('orboto_x', 'Does the thing. And much, much more follows here.'))
25
+ .toBe('Does the thing.');
26
+ });
27
+ it('uses the hand-written override when one exists', () => {
28
+ const s = summarizeToolDescription('orboto_get_ticket', 'A very long first sentence that would otherwise be cut mid-flow because it enumerates every field the response carries and never stops');
29
+ expect(s).toContain('Fetch one ticket');
30
+ expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
31
+ });
32
+ it('falls back to a word-boundary cut when the first sentence overruns the cap', () => {
33
+ const long = `${'word '.repeat(80)}end.`;
34
+ const s = summarizeToolDescription('orboto_unknown_tool', long);
35
+ expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
36
+ expect(s.endsWith('...')).toBe(true);
37
+ expect(s).not.toMatch(/\swor\.\.\.$/); // never cuts inside a word
38
+ });
39
+ });
40
+ describe('orboto_help handler', () => {
41
+ it('returns the captured full text, resolves the orboto_ prefix, and lists known tools on a miss', async () => {
42
+ captureToolDoc('orboto_demo_tool', 'Short summary. Long tail of guidance with warnings and workflows.');
43
+ const handler = makeHelpHandler();
44
+ const hit = await handler({ tool: 'orboto_demo_tool' });
45
+ expect(hit.structuredContent).toMatchObject({
46
+ tool: 'orboto_demo_tool',
47
+ guidance: expect.stringContaining('Long tail of guidance'),
48
+ });
49
+ const prefixed = await handler({ tool: 'demo_tool' });
50
+ expect(prefixed.structuredContent).toMatchObject({ tool: 'orboto_demo_tool' });
51
+ const miss = await handler({ tool: 'orboto_nope' });
52
+ expect(miss.content[0].text).toContain('No guidance registered');
53
+ expect(miss.content[0].text).toContain('orboto_demo_tool');
54
+ });
55
+ });
56
+ describe('live manifest vs help registry (no guidance lost)', () => {
57
+ it('every tool in the full manifest has a one-line wire description and full docs behind orboto_help', async () => {
58
+ const server = await buildOrbotoMcpServer({
59
+ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full',
60
+ });
61
+ const [ct, st] = InMemoryTransport.createLinkedPair();
62
+ const client = new Client({ name: 'diet-check', version: '0.0.0' });
63
+ await Promise.all([server.connect(st), client.connect(ct)]);
64
+ try {
65
+ const { tools } = await client.listTools({});
66
+ expect(tools.length).toBeGreaterThan(150);
67
+ for (const t of tools) {
68
+ const wire = t.description ?? '';
69
+ // Acceptance: no registered wire description exceeds the cap.
70
+ expect(wire.length, `${t.name} wire description too long`).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
71
+ // The full text is behind the registry and STARTS where the
72
+ // summary came from (override tools aside, whose full text is
73
+ // still captured verbatim).
74
+ const full = getToolDoc(t.name);
75
+ expect(full, `${t.name} missing from the help registry`).toBeTruthy();
76
+ expect(summarizeToolDescription(t.name, full)).toBe(wire);
77
+ }
78
+ // Spot-check the measured worst offender end to end via the tool.
79
+ const res = await client.callTool({ name: 'orboto_help', arguments: { tool: 'orboto_create_ticket' } });
80
+ // First block can be the ORB-1331 session-start nudge - join all.
81
+ const text = res.content.map((c) => c.text ?? '').join('\n');
82
+ expect(text.length).toBeGreaterThan(1000); // the old 2.9k essay, intact
83
+ expect(text).toContain('Duplicate-detection safety-net');
84
+ }
85
+ finally {
86
+ await client.close();
87
+ await server.close();
88
+ }
89
+ });
90
+ });