@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,293 @@
1
+ /**
2
+ * ORB-1692 - strict tool inputs + the aliases agents actually send.
3
+ *
4
+ * Measured across 32 transcripts: 55.6% of all MCP errors were "Required"
5
+ * validation failures where the agent guessed a plausible-but-wrong
6
+ * parameter name (`body` for `text`, `parentKey` for `parentTicketKey`,
7
+ * ...). Worse, zod strips unknown keys by default, so a wrong key on an
8
+ * otherwise-valid call VANISHED silently - `create_ticket` with
9
+ * `parentKey` created ORB-1684 with no parent and no warning.
10
+ *
11
+ * Fix, applied centrally at registration (with-metrics `reg()`):
12
+ * 1. every tool input becomes `.strict()` - an unknown key errors,
13
+ * naming the offender;
14
+ * 2. the measured aliases resolve silently to the canonical field via a
15
+ * GUARDED global rename: `alias -> canonical` fires only when the
16
+ * canonical field exists in that tool's shape, the alias does not,
17
+ * and the caller did not also send the canonical - so a tool whose
18
+ * REAL field is `body` (agent-instruction blocks) is never touched;
19
+ * 3. `orboto_update_ticket` folds flat patch fields into `patch` when no
20
+ * `patch` was sent - agents routinely write `{ticketKey, description}`.
21
+ *
22
+ * ORB-1817 - "we do not force keys that make no sense - key and query are
23
+ * sensible names" (operator). Extends the alias table with the sensible
24
+ * spellings a caller actually reaches for (`key`, `query`, `projectId`,
25
+ * ...) plus one DYNAMIC alias - `id` resolves to the tool's own single
26
+ * id-shaped parameter (`ticketKey` / `docId` / `milestone` / `commentId`)
27
+ * only when exactly one of those exists in that tool's shape - and adds a
28
+ * teaching error: when a call still can't be resolved, the thrown message
29
+ * is a structured JSON block naming every parameter the tool accepts plus
30
+ * a Levenshtein<=2 guess for each unrecognized key (same distance the mjs
31
+ * wrapper's `suggestFlag` uses for `--flag` typos). The failure is also
32
+ * logged through the same `/admin/mcp/instrument` path a handler error
33
+ * uses - see the module doc on `mcp-instrument.ts` for why that needs a
34
+ * dedicated leaf module instead of importing from with-metrics.ts.
35
+ *
36
+ * SDK mechanics: `registerTool` accepts either a raw shape or a zod
37
+ * schema. A top-level `z.preprocess(...)` has no `.shape`, which would
38
+ * make the SDK advertise an EMPTY input schema in tools/list. Stamping
39
+ * the inner object's `.shape` onto the effects schema keeps the
40
+ * advertisement byte-identical to the strict object (verified: the SDK's
41
+ * `normalizeObjectSchema` only checks `.shape !== undefined`, and its
42
+ * json-schema emitter unwraps effects with pipeStrategy "input").
43
+ *
44
+ * ORB-1817 - the preprocess callback is also where the teaching error is
45
+ * thrown (verified against @modelcontextprotocol/sdk 1.29's zod-compat +
46
+ * server/mcp.js): a `preprocess` effect calls `effect.transform(data,
47
+ * checkCtx)` BEFORE the inner schema ever parses, and unlike `refine` /
48
+ * `transform`, a synchronous throw from inside it is NOT caught by zod -
49
+ * it propagates out of `ZodEffects._parse`, out of the SDK's
50
+ * `safeParseAsync` (converted to a rejected promise, since that's an
51
+ * `async function`), and lands in the SDK's own `CallToolRequestSchema`
52
+ * handler's outer try/catch, which for a plain (non-McpError) `Error`
53
+ * returns `{isError: true, content: [{type: 'text', text: error.message}]}`
54
+ * with NO extra wrapping. That is what lets us hand back exactly our
55
+ * structured JSON with nothing else attached - `.superRefine()` cannot do
56
+ * this (it never even runs once the base object parse has already failed,
57
+ * which is the common case: a missing-required or unrecognized-key input).
58
+ */
59
+ import { z } from 'zod';
60
+ import { postLogEntry, redactSecrets } from './mcp-instrument.js';
61
+ /**
62
+ * alias -> canonical. Applied to every tool, guarded by the tool's own
63
+ * shape (see module doc). Sourced from the ORB-1692 / ORB-1817 measurement
64
+ * tables - extend it when a NEW misfire class shows up in the
65
+ * mcp_call_log, not speculatively.
66
+ */
67
+ export const GLOBAL_INPUT_ALIASES = {
68
+ body: 'text',
69
+ parentKey: 'parentTicketKey',
70
+ dependsOnTicketKey: 'dependsOnKey',
71
+ docKey: 'docId',
72
+ status: 'statusCategory',
73
+ milestoneKey: 'milestone',
74
+ ticket: 'ticketKey',
75
+ project: 'projectKey',
76
+ // ORB-1817 - measured 2026-09-03: both cost one retry in the same
77
+ // session. "key" and "query" are what a reasonable caller types.
78
+ key: 'ticketKey',
79
+ query: 'oql',
80
+ projectId: 'projectKey',
81
+ milestoneId: 'milestone',
82
+ comment: 'text',
83
+ message: 'text',
84
+ // Bulk tools all take `ticketKeys` (plural) - singular-sounding `keys`
85
+ // is the natural typo/guess.
86
+ keys: 'ticketKeys',
87
+ max: 'limit',
88
+ page: 'cursor',
89
+ };
90
+ /** ORB-1817 - `id` has no single canonical target across tools (it's
91
+ * `ticketKey` on a ticket tool, `docId` on a doc tool, ...), so it can't
92
+ * be a static GLOBAL_INPUT_ALIASES entry. Resolved dynamically: only
93
+ * when the tool's shape has EXACTLY ONE of these, and only when the
94
+ * caller didn't already send it explicitly. Two or zero matches leave
95
+ * `id` alone so the strict error - now the teaching error - explains why. */
96
+ const ID_LIKE_CANONICALS = ['ticketKey', 'docId', 'milestone', 'commentId'];
97
+ /** The flat fields orboto_update_ticket folds into `patch` (ORB-1692 #3). */
98
+ const UPDATE_TICKET_PATCH_KEYS = [
99
+ 'title', 'description', 'customerSummary', 'type', 'priority',
100
+ 'deliveryMode', 'dueDate', 'startDate', 'isPrivate', 'estimatedTimeMinutes',
101
+ ];
102
+ function applyAliases(toolName, shape, value) {
103
+ if (!value || typeof value !== 'object' || Array.isArray(value))
104
+ return value;
105
+ let out = value;
106
+ for (const [alias, canonical] of Object.entries(GLOBAL_INPUT_ALIASES)) {
107
+ if (alias in out &&
108
+ !(canonical in out) &&
109
+ canonical in shape &&
110
+ !(alias in shape)) {
111
+ const { [alias]: aliased, ...rest } = out;
112
+ out = { ...rest, [canonical]: aliased };
113
+ }
114
+ }
115
+ // ORB-1817 - `id` -> the tool's single id-shaped parameter.
116
+ if ('id' in out && !('id' in shape)) {
117
+ const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out));
118
+ if (candidates.length === 1) {
119
+ const canonical = candidates[0];
120
+ const { id, ...rest } = out;
121
+ out = { ...rest, [canonical]: id };
122
+ }
123
+ }
124
+ // update_ticket: flat fields become an implicit patch. Only when the
125
+ // caller sent no patch at all - a partial patch plus flat extras stays
126
+ // an error (ambiguous intent must not be guessed).
127
+ if (toolName === 'orboto_update_ticket' && !('patch' in out)) {
128
+ const patch = {};
129
+ const rest = {};
130
+ let folded = false;
131
+ for (const [k, v] of Object.entries(out)) {
132
+ if (UPDATE_TICKET_PATCH_KEYS.includes(k)) {
133
+ patch[k] = v;
134
+ folded = true;
135
+ }
136
+ else {
137
+ rest[k] = v;
138
+ }
139
+ }
140
+ if (folded)
141
+ out = { ...rest, patch };
142
+ }
143
+ return out;
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // ORB-1817 - teaching error: structured `expected` block + closest-name
147
+ // guess, built when the strict + alias-resolved value STILL doesn't parse.
148
+ // ---------------------------------------------------------------------------
149
+ /** Levenshtein-1/2 match, same algorithm skills/orboto/scripts/orboto.mjs
150
+ * uses for `--flag` typos (`suggestFlag` / `levenshtein`) - keep them in
151
+ * sync if the distance metric ever changes. */
152
+ function levenshtein(a, b) {
153
+ const m = a.length;
154
+ const n = b.length;
155
+ if (!m)
156
+ return n;
157
+ if (!n)
158
+ return m;
159
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
160
+ for (let j = 1; j <= n; j++)
161
+ dp[0][j] = j;
162
+ for (let i = 1; i <= m; i++) {
163
+ for (let j = 1; j <= n; j++) {
164
+ dp[i][j] = a[i - 1] === b[j - 1]
165
+ ? dp[i - 1][j - 1]
166
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
167
+ }
168
+ }
169
+ return dp[m][n];
170
+ }
171
+ function closestName(typed, candidates, maxDistance = 2) {
172
+ const near = candidates.filter((c) => levenshtein(typed, c) <= maxDistance);
173
+ return near.sort((a, b) => levenshtein(typed, a) - levenshtein(typed, b))[0];
174
+ }
175
+ /** Peel ZodOptional / ZodDefault / ZodNullable to name the underlying
176
+ * type; everything else maps to a short lowercase type name so the
177
+ * block stays compact (this is for a tool-calling model, not a schema
178
+ * viewer). */
179
+ function describeZodType(schema) {
180
+ const def = schema._def;
181
+ switch (def.typeName) {
182
+ case 'ZodOptional':
183
+ case 'ZodDefault':
184
+ case 'ZodNullable':
185
+ return def.innerType ? describeZodType(def.innerType) : 'unknown';
186
+ case 'ZodString': return 'string';
187
+ case 'ZodNumber': return 'number';
188
+ case 'ZodBoolean': return 'boolean';
189
+ case 'ZodArray': return 'array';
190
+ case 'ZodObject': return 'object';
191
+ case 'ZodEnum': return `enum(${(def.values ?? []).join('|')})`;
192
+ case 'ZodUnion': return 'union';
193
+ default: return def.typeName.replace(/^Zod/, '').toLowerCase() || 'unknown';
194
+ }
195
+ }
196
+ function describeShape(shape) {
197
+ return Object.entries(shape)
198
+ .map(([name, schema]) => ({
199
+ name,
200
+ type: describeZodType(schema),
201
+ required: !schema.isOptional(),
202
+ description: schema.description,
203
+ }))
204
+ .sort((a, b) => a.name.localeCompare(b.name));
205
+ }
206
+ /**
207
+ * Build the teaching-error message: ONE structured JSON block, not prose.
208
+ * `code: -32602` mirrors the JSON-RPC "invalid params" code the SDK would
209
+ * otherwise bury inside its own generic zod-issue dump.
210
+ */
211
+ function buildTeachingError(toolName, shape, issues) {
212
+ const validNames = Object.keys(shape);
213
+ const unrecognized = [];
214
+ const missing = [];
215
+ for (const issue of issues) {
216
+ if (issue.code === 'unrecognized_keys')
217
+ unrecognized.push(...issue.keys);
218
+ if (issue.code === 'invalid_type' && issue.received === 'undefined' && issue.path.length === 1) {
219
+ missing.push(String(issue.path[0]));
220
+ }
221
+ }
222
+ const didYouMean = {};
223
+ for (const key of unrecognized) {
224
+ const guess = closestName(key, validNames);
225
+ if (guess)
226
+ didYouMean[key] = guess;
227
+ }
228
+ const summary = unrecognized.length > 0
229
+ ? `Unrecognized parameter(s) for ${toolName}: ${unrecognized.join(', ')}.`
230
+ : missing.length > 0
231
+ ? `Missing required parameter(s) for ${toolName}: ${missing.join(', ')}.`
232
+ : `Invalid arguments for ${toolName}.`;
233
+ return JSON.stringify({
234
+ code: -32602,
235
+ error: summary,
236
+ expected: {
237
+ tool: toolName,
238
+ parameters: describeShape(shape),
239
+ },
240
+ unrecognized: unrecognized.length ? unrecognized : undefined,
241
+ missing: missing.length ? missing : undefined,
242
+ didYouMean: Object.keys(didYouMean).length ? didYouMean : undefined,
243
+ });
244
+ }
245
+ /**
246
+ * Wrap a raw tool shape into the strict + alias-resolving schema the SDK
247
+ * validates against. Returns a schema whose `.shape` is stamped so
248
+ * tools/list advertisement stays identical to the plain object form.
249
+ *
250
+ * `client` + `clientHint` are optional (tests construct schemas without
251
+ * them) - when present, a validation failure that survives aliasing is
252
+ * logged through the SAME `/admin/mcp/instrument` path a handler error
253
+ * uses (ORB-1817 Part C), because the SDK validates input BEFORE
254
+ * with-metrics.ts's handler wrapper ever runs - this preprocess step is
255
+ * the only place that ever sees the failure.
256
+ */
257
+ export function buildStrictInputSchema(toolName, shape, client, clientHint) {
258
+ const inner = z.object(shape).strict();
259
+ const effects = z.preprocess((value) => {
260
+ const start = Date.now();
261
+ const aliased = applyAliases(toolName, shape, value);
262
+ const parsed = inner.safeParse(aliased);
263
+ if (parsed.success)
264
+ return aliased;
265
+ const message = buildTeachingError(toolName, shape, parsed.error.issues);
266
+ if (client) {
267
+ void postLogEntry(client, {
268
+ toolName,
269
+ durationMs: Date.now() - start,
270
+ success: false,
271
+ statusCode: -32602,
272
+ errorMessage: redactSecrets(message).slice(0, 500),
273
+ clientHint,
274
+ });
275
+ }
276
+ // See the module doc: thrown here (inside a "preprocess" effect, not
277
+ // a refinement/transform), this is NOT caught by zod - it becomes the
278
+ // exact isError text the caller sees.
279
+ throw new Error(message);
280
+ }, inner);
281
+ // SDK contract (see module doc): .shape must exist for advertisement.
282
+ effects.shape = inner.shape;
283
+ return effects;
284
+ }
285
+ /** True when the value is a raw shape (plain record of zod schemas), not a schema instance. */
286
+ export function isRawShape(value) {
287
+ if (!value || typeof value !== 'object')
288
+ return false;
289
+ if (value._def !== undefined)
290
+ return false;
291
+ const entries = Object.values(value);
292
+ return entries.length > 0 && entries.every((v) => typeof v === 'object' && v !== null && v._def !== undefined);
293
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * ORB-1692 / ORB-1817 - strict inputs + measured aliases + the teaching
3
+ * error.
4
+ *
5
+ * Table-driven against the REAL tool configs: each alias from the
6
+ * measurement table must resolve to the canonical field, unknown keys
7
+ * must error naming the offender, and the ORB-1684 silent-drop case
8
+ * (create_ticket with parentKey) must never come back.
9
+ *
10
+ * ORB-1817 - a call that still can't be resolved after aliasing now
11
+ * THROWS (from every parse entry point, `.safeParse()` included - see
12
+ * the module doc on `buildStrictInputSchema` for why: the teaching error
13
+ * is thrown from inside a zod "preprocess" effect, which runs before the
14
+ * sync/async branch even exists). That's an intentional behaviour change
15
+ * from plain zod (whose `.safeParse()` never throws) - every test below
16
+ * that used to check `parsed.success === false` now checks the throw.
17
+ */
18
+ import { describe, it, expect, vi } from 'vitest';
19
+ import { z } from 'zod';
20
+ import { buildStrictInputSchema, isRawShape, GLOBAL_INPUT_ALIASES } from './input-schema.js';
21
+ import { commentToolConfig, updateTicketToolConfig, addTicketDependencyToolConfig, moveTicketToolConfig, createTicketToolConfig, updateCommentToolConfig, } from './tools/ticket-writes.js';
22
+ import { setParentToolConfig } from './tools/set-parent.js';
23
+ import { getMilestoneToolConfig } from './tools/milestones.js';
24
+ import { getTicketToolConfig } from './tools/get-ticket.js';
25
+ import { listTicketsToolConfig } from './tools/list-tickets.js';
26
+ import { getDocToolConfig } from './tools/docs.js';
27
+ import { queryToolConfig } from './tools/query.js';
28
+ import { bulkMoveTicketsToolConfig } from './tools/bulk-writes.js';
29
+ function schemaFor(toolName, config) {
30
+ return buildStrictInputSchema(toolName, config.inputSchema);
31
+ }
32
+ /** Parse and return the thrown message's parsed JSON body (ORB-1817: an
33
+ * invalid call throws instead of returning `{success:false}`). */
34
+ function throwsWith(schema, value) {
35
+ let caught;
36
+ try {
37
+ schema.safeParse(value);
38
+ }
39
+ catch (err) {
40
+ caught = err;
41
+ }
42
+ expect(caught).toBeInstanceOf(Error);
43
+ return JSON.parse(caught.message);
44
+ }
45
+ describe('measured aliases resolve to the canonical field', () => {
46
+ const CASES = [
47
+ { tool: 'orboto_comment', config: commentToolConfig, send: { ticketKey: 'ORB-1', body: 'hi' }, expectKey: 'text', expectValue: 'hi' },
48
+ { tool: 'orboto_add_ticket_dependency', config: addTicketDependencyToolConfig, send: { ticketKey: 'ORB-1', dependsOnTicketKey: 'ORB-2' }, expectKey: 'dependsOnKey', expectValue: 'ORB-2' },
49
+ { tool: 'orboto_get_doc', config: getDocToolConfig, send: { docKey: 'ORB-D12' }, expectKey: 'docId', expectValue: 'ORB-D12' },
50
+ { tool: 'orboto_move_ticket', config: moveTicketToolConfig, send: { ticketKey: 'ORB-1', status: 'done' }, expectKey: 'statusCategory', expectValue: 'done' },
51
+ { tool: 'orboto_set_parent', config: setParentToolConfig, send: { ticketKey: 'ORB-1', parentKey: 'ORB-9' }, expectKey: 'parentTicketKey', expectValue: 'ORB-9' },
52
+ { tool: 'orboto_get_milestone', config: getMilestoneToolConfig, send: { projectKey: 'ORB', milestoneKey: 'ORB-M3' }, expectKey: 'milestone', expectValue: 'ORB-M3' },
53
+ { tool: 'orboto_get_ticket', config: getTicketToolConfig, send: { ticket: 'ORB-1' }, expectKey: 'ticketKey', expectValue: 'ORB-1' },
54
+ { tool: 'orboto_list_tickets', config: listTicketsToolConfig, send: { project: 'ORB' }, expectKey: 'projectKey', expectValue: 'ORB' },
55
+ ];
56
+ for (const c of CASES) {
57
+ it(`${c.tool}: ${Object.keys(c.send).join('+')} -> ${c.expectKey}`, () => {
58
+ const parsed = schemaFor(c.tool, c.config).safeParse(c.send);
59
+ expect(parsed.success, JSON.stringify('error' in parsed ? parsed.error : '')).toBe(true);
60
+ if (parsed.success) {
61
+ const data = parsed.data;
62
+ expect(data[c.expectKey]).toBe(c.expectValue);
63
+ expect(Object.keys(c.send).find((k) => GLOBAL_INPUT_ALIASES[k]) in data).toBe(false);
64
+ }
65
+ });
66
+ }
67
+ });
68
+ describe('the ORB-1684 regression: silent drops are dead', () => {
69
+ it('create_ticket with parentKey sets parentTicketKey - never vanishes', () => {
70
+ const parsed = schemaFor('orboto_create_ticket', createTicketToolConfig)
71
+ .safeParse({ projectKey: 'ORB', title: 'x', parentKey: 'ORB-1691' });
72
+ expect(parsed.success).toBe(true);
73
+ if (parsed.success) {
74
+ expect(parsed.data.parentTicketKey).toBe('ORB-1691');
75
+ }
76
+ });
77
+ it('an unknown key errors and names the offender', () => {
78
+ const body = throwsWith(schemaFor('orboto_create_ticket', createTicketToolConfig), { projectKey: 'ORB', title: 'x', bogusField: 1 });
79
+ expect(body.unrecognized).toEqual(['bogusField']);
80
+ });
81
+ });
82
+ describe('update_ticket: flat fields fold into patch', () => {
83
+ it('{ticketKey, description} becomes {ticketKey, patch:{description}}', () => {
84
+ const parsed = schemaFor('orboto_update_ticket', updateTicketToolConfig)
85
+ .safeParse({ ticketKey: 'ORB-1', description: 'new text', priority: 'high' });
86
+ expect(parsed.success).toBe(true);
87
+ if (parsed.success) {
88
+ const data = parsed.data;
89
+ expect(data.patch).toEqual({ description: 'new text', priority: 'high' });
90
+ }
91
+ });
92
+ it('an explicit patch wins - flat extras next to a patch stay an error', () => {
93
+ const body = throwsWith(schemaFor('orboto_update_ticket', updateTicketToolConfig), { ticketKey: 'ORB-1', patch: { title: 'a' }, description: 'b' });
94
+ expect(body.unrecognized).toEqual(['description']);
95
+ });
96
+ });
97
+ describe('alias guard: canonical-field collisions are never renamed', () => {
98
+ it('a tool whose REAL field is `body` keeps it', () => {
99
+ const shape = { id: z.string(), body: z.string() };
100
+ const parsed = buildStrictInputSchema('orboto_update_agent_instruction', shape)
101
+ .safeParse({ id: 'x', body: 'the rule text' });
102
+ expect(parsed.success).toBe(true);
103
+ if (parsed.success) {
104
+ expect(parsed.data.body).toBe('the rule text');
105
+ }
106
+ });
107
+ it('alias is ignored when the caller also sent the canonical field', () => {
108
+ // both present: no rename happens, so `body` is an unknown key -> error
109
+ const body = throwsWith(schemaFor('orboto_comment', commentToolConfig), { ticketKey: 'ORB-1', text: 'real', body: 'stray' });
110
+ expect(body.unrecognized).toEqual(['body']);
111
+ });
112
+ });
113
+ describe('ORB-1817: extended alias table', () => {
114
+ const CASES = [
115
+ { tool: 'orboto_get_ticket', config: getTicketToolConfig, send: { key: 'ORB-1' }, expectKey: 'ticketKey', expectValue: 'ORB-1' },
116
+ { tool: 'orboto_query', config: queryToolConfig, send: { query: 'project = ORB' }, expectKey: 'oql', expectValue: 'project = ORB' },
117
+ { tool: 'orboto_get_milestone', config: getMilestoneToolConfig, send: { projectKey: 'ORB', milestoneId: 'ORB-M3' }, expectKey: 'milestone', expectValue: 'ORB-M3' },
118
+ { tool: 'orboto_list_tickets', config: listTicketsToolConfig, send: { projectId: 'ORB' }, expectKey: 'projectKey', expectValue: 'ORB' },
119
+ { tool: 'orboto_comment', config: commentToolConfig, send: { ticketKey: 'ORB-1', comment: 'hi' }, expectKey: 'text', expectValue: 'hi' },
120
+ { tool: 'orboto_comment', config: commentToolConfig, send: { ticketKey: 'ORB-1', message: 'hi' }, expectKey: 'text', expectValue: 'hi' },
121
+ { tool: 'orboto_bulk_move_tickets', config: bulkMoveTicketsToolConfig, send: { keys: ['ORB-1', 'ORB-2'], statusCategory: 'done' }, expectKey: 'ticketKeys', expectValue: ['ORB-1', 'ORB-2'] },
122
+ { tool: 'orboto_query', config: queryToolConfig, send: { oql: 'project = ORB', max: 10 }, expectKey: 'limit', expectValue: 10 },
123
+ { tool: 'orboto_query', config: queryToolConfig, send: { oql: 'project = ORB', page: 'c1' }, expectKey: 'cursor', expectValue: 'c1' },
124
+ // `id` -> the tool's single id-shaped parameter (ticketKey here).
125
+ { tool: 'orboto_get_ticket', config: getTicketToolConfig, send: { id: 'ORB-1' }, expectKey: 'ticketKey', expectValue: 'ORB-1' },
126
+ { tool: 'orboto_get_doc', config: getDocToolConfig, send: { id: 'ORB-D12' }, expectKey: 'docId', expectValue: 'ORB-D12' },
127
+ ];
128
+ for (const c of CASES) {
129
+ it(`${c.tool}: ${Object.keys(c.send).join('+')} -> ${c.expectKey}`, () => {
130
+ const parsed = schemaFor(c.tool, c.config).safeParse(c.send);
131
+ expect(parsed.success, JSON.stringify('error' in parsed ? parsed.error : '')).toBe(true);
132
+ if (parsed.success) {
133
+ const data = parsed.data;
134
+ expect(data[c.expectKey]).toEqual(c.expectValue);
135
+ }
136
+ });
137
+ }
138
+ it('`id` is left alone (and errors) when the tool has more than one id-shaped parameter', () => {
139
+ // orboto_update_comment has BOTH ticketKey and commentId - ambiguous.
140
+ const body = throwsWith(schemaFor('orboto_update_comment', updateCommentToolConfig), { id: 'ORB-1', text: 'hi' });
141
+ expect(body.unrecognized).toEqual(['id']);
142
+ });
143
+ });
144
+ describe('ORB-1817: teaching error', () => {
145
+ it('names every parameter the tool accepts', () => {
146
+ const body = throwsWith(schemaFor('orboto_get_ticket', getTicketToolConfig), { bogus: 1 });
147
+ expect(body.code).toBe(-32602);
148
+ const expected = body.expected;
149
+ expect(expected.tool).toBe('orboto_get_ticket');
150
+ expect(expected.parameters.map((p) => p.name)).toContain('ticketKey');
151
+ const ticketKeyParam = expected.parameters.find((p) => p.name === 'ticketKey');
152
+ expect(ticketKeyParam?.required).toBe(true);
153
+ });
154
+ it('suggests the closest valid name for a typo (Levenshtein <= 2)', () => {
155
+ const body = throwsWith(schemaFor('orboto_get_ticket', getTicketToolConfig), { ticketKye: 'ORB-1' });
156
+ expect(body.didYouMean).toEqual({ ticketKye: 'ticketKey' });
157
+ });
158
+ it('names missing required parameters when nothing is unrecognized', () => {
159
+ const body = throwsWith(schemaFor('orboto_add_ticket_dependency', addTicketDependencyToolConfig), { ticketKey: 'ORB-1' });
160
+ expect(body.missing).toEqual(['dependsOnKey']);
161
+ expect(body.unrecognized).toBeUndefined();
162
+ });
163
+ });
164
+ describe('ORB-1817: validation failures are instrumented (Part C)', () => {
165
+ it('a bad input logs through the same /admin/mcp/instrument path a handler error uses', () => {
166
+ const post = vi.fn().mockResolvedValue(undefined);
167
+ const fakeClient = { post };
168
+ const schema = buildStrictInputSchema('orboto_get_ticket', getTicketToolConfig.inputSchema, fakeClient, 'test-client');
169
+ expect(() => schema.safeParse({ bogus: 1 })).toThrow();
170
+ expect(post).toHaveBeenCalledTimes(1);
171
+ expect(post).toHaveBeenCalledWith('/admin/mcp/instrument', expect.objectContaining({
172
+ toolName: 'orboto_get_ticket',
173
+ success: false,
174
+ statusCode: -32602,
175
+ clientHint: 'test-client',
176
+ }));
177
+ });
178
+ it('a valid input never logs', () => {
179
+ const post = vi.fn().mockResolvedValue(undefined);
180
+ const fakeClient = { post };
181
+ const schema = buildStrictInputSchema('orboto_get_ticket', getTicketToolConfig.inputSchema, fakeClient, 'test-client');
182
+ schema.safeParse({ ticketKey: 'ORB-1' });
183
+ expect(post).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+ describe('SDK advertisement contract', () => {
187
+ it('the wrapped schema keeps .shape (tools/list must not go empty)', () => {
188
+ const s = schemaFor('orboto_comment', commentToolConfig);
189
+ expect(s.shape).toBeDefined();
190
+ expect(Object.keys(s.shape ?? {})).toContain('text');
191
+ });
192
+ it('isRawShape tells shapes from schema instances', () => {
193
+ expect(isRawShape(commentToolConfig.inputSchema)).toBe(true);
194
+ expect(isRawShape(z.object({ a: z.string() }))).toBe(false);
195
+ expect(isRawShape({})).toBe(false);
196
+ expect(isRawShape(undefined)).toBe(false);
197
+ });
198
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ORB-1177 - the instructions block stays under a length budget so the
3
+ * client can't silently truncate it mid-rule; the head always survives
4
+ * and the rules are cut at a whole-line boundary with a pointer to the
5
+ * cap-independent full set.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { assembleInstructions } from './server.js';
9
+ const HEADING = 'Working rules for this workspace:\n';
10
+ describe('assembleInstructions (ORB-1177)', () => {
11
+ it('passes through unchanged when under budget', () => {
12
+ const out = assembleInstructions('HEAD', 'rule one\nrule two', 1000);
13
+ expect(out).toBe(`HEAD\n\n${HEADING}rule one\nrule two`);
14
+ });
15
+ it('caps at the budget, keeps the head, cuts rules on a line boundary, points to the full set', () => {
16
+ const head = 'HEAD-ABC';
17
+ const lines = Array.from({ length: 300 }, (_, i) => `rule-line-${i}-padding-padding-padding-padding`);
18
+ const rules = lines.join('\n');
19
+ const budget = 600;
20
+ const out = assembleInstructions(head, rules, budget);
21
+ expect(out.length).toBeLessThanOrEqual(budget);
22
+ expect(out.startsWith(`${head}\n\n${HEADING}`)).toBe(true);
23
+ expect(out).toContain('orboto://rules');
24
+ expect(out).toContain('orboto_session_start');
25
+ expect(out).toContain('truncated');
26
+ // Every kept rule line is a COMPLETE original line (no mid-line cut).
27
+ const body = out.slice(`${head}\n\n${HEADING}`.length).split('\n\n[... rules truncated')[0];
28
+ const kept = body.length ? body.split('\n') : [];
29
+ for (const line of kept)
30
+ expect(lines).toContain(line);
31
+ // And it actually kept fewer than all lines (proves truncation happened).
32
+ expect(kept.length).toBeLessThan(lines.length);
33
+ });
34
+ });