@orboto/mcp 0.230.7 → 0.230.9
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.
- package/dist/channel-toolset.test.js +18 -4
- package/dist/empty-surfaces.js +21 -0
- package/dist/server.js +11 -2
- package/dist/test/stdio-probe.js +79 -0
- package/dist/tools/agent-coordination.js +1 -1
- package/dist/tools/agent-session-scope.js +2 -2
- package/dist/tools/api-call.js +2 -1
- package/dist/tools/bulk-create.js +2 -2
- package/dist/tools/doc-edits.js +1 -1
- package/dist/tools/docs.js +1 -1
- package/dist/tools/get-ticket.js +3 -1
- package/dist/tools/import-numbering.js +156 -0
- package/dist/tools/import-numbering.test.js +166 -0
- package/dist/tools/phase-b-tools.test.js +23 -0
- package/dist/tools/search.js +1 -1
- package/dist/tools/spec-schemas.js +1 -1
- package/dist/tools/ticket-schedules.js +2 -2
- package/dist/tools/ticket-writes.js +2 -2
- package/dist/tools/work-sessions.js +1 -1
- package/dist/toolset-list-methods.stdio.test.js +53 -0
- package/dist/zod-bounds.js +157 -0
- package/dist/zod-bounds.test.js +39 -0
- package/package.json +1 -1
|
@@ -45,17 +45,31 @@ describe('resolveToolset / toolInToolset - channel', () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
describe('ORB-2210 - the channel toolset', () => {
|
|
48
|
-
it('starts, answers initialize,
|
|
48
|
+
it('starts, answers initialize, offers no tool, prompt or resource, and fetches no rules at connect', async () => {
|
|
49
49
|
const { server, client, fetchSpy } = await connectChannelServer();
|
|
50
50
|
expect(client.getServerVersion()?.name).toBe('orboto');
|
|
51
|
-
expect(client.getServerCapabilities()?.tools).
|
|
52
|
-
expect(client.getServerCapabilities()?.prompts).
|
|
53
|
-
expect(client.getServerCapabilities()?.resources).
|
|
51
|
+
expect(client.getServerCapabilities()?.tools).toEqual({});
|
|
52
|
+
expect(client.getServerCapabilities()?.prompts).toEqual({});
|
|
53
|
+
expect(client.getServerCapabilities()?.resources).toEqual({});
|
|
54
54
|
expect(client.getServerCapabilities()?.experimental).toHaveProperty(CHANNEL_CAPABILITY);
|
|
55
|
+
expect((await client.listTools()).tools).toEqual([]);
|
|
56
|
+
expect((await client.listPrompts()).prompts).toEqual([]);
|
|
57
|
+
expect((await client.listResources()).resources).toEqual([]);
|
|
58
|
+
expect((await client.listResourceTemplates()).resourceTemplates).toEqual([]);
|
|
55
59
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
56
60
|
await client.close();
|
|
57
61
|
await server.close();
|
|
58
62
|
});
|
|
63
|
+
it('ORB-2669 - a name nothing registered keeps the not-found answers of the other toolsets', async () => {
|
|
64
|
+
const { server, client } = await connectChannelServer();
|
|
65
|
+
const result = await client.callTool({ name: 'orboto_get_ticket', arguments: {} });
|
|
66
|
+
expect(result.isError).toBe(true);
|
|
67
|
+
expect(result.content).toEqual([{ type: 'text', text: 'MCP error -32602: Tool orboto_get_ticket not found' }]);
|
|
68
|
+
await expect(client.getPrompt({ name: 'plan-sprint' })).rejects.toMatchObject({ code: -32602 });
|
|
69
|
+
await expect(client.readResource({ uri: 'orboto://rules' })).rejects.toMatchObject({ code: -32602 });
|
|
70
|
+
await client.close();
|
|
71
|
+
await server.close();
|
|
72
|
+
});
|
|
59
73
|
it('instructions point at the CLI, the scope command and the way back to the tools', async () => {
|
|
60
74
|
const { server, client } = await connectChannelServer();
|
|
61
75
|
const text = client.getInstructions() ?? '';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
/** Declared at construction: the SDK refuses a request handler whose capability is not declared. */
|
|
3
|
+
export const EMPTY_SURFACE_CAPABILITIES = { tools: {}, prompts: {}, resources: {} };
|
|
4
|
+
/** Empty lists, and the not-found answers the SDK gives for a name nothing registered. */
|
|
5
|
+
export function registerEmptySurfaces(server) {
|
|
6
|
+
const low = server.server;
|
|
7
|
+
low.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
|
8
|
+
low.setRequestHandler(CallToolRequestSchema, (req) => ({
|
|
9
|
+
content: [{ type: 'text', text: new McpError(ErrorCode.InvalidParams, `Tool ${req.params.name} not found`).message }],
|
|
10
|
+
isError: true,
|
|
11
|
+
}));
|
|
12
|
+
low.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
|
|
13
|
+
low.setRequestHandler(GetPromptRequestSchema, (req) => {
|
|
14
|
+
throw new McpError(ErrorCode.InvalidParams, `Prompt ${req.params.name} not found`);
|
|
15
|
+
});
|
|
16
|
+
low.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
|
17
|
+
low.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({ resourceTemplates: [] }));
|
|
18
|
+
low.setRequestHandler(ReadResourceRequestSchema, (req) => {
|
|
19
|
+
throw new McpError(ErrorCode.InvalidParams, `Resource ${req.params.uri} not found`);
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
5
|
import { VERSION } from './version.js';
|
|
6
|
+
import { assertZodCollectionBounds, installZodCollectionBounds } from './zod-bounds.js';
|
|
6
7
|
import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
8
|
import { OrbotoClient } from './orboto-client.js';
|
|
8
9
|
import { registerOrbotoResources } from './resources.js';
|
|
9
10
|
import { registerOrbotoPrompts } from './prompts.js';
|
|
11
|
+
import { EMPTY_SURFACE_CAPABILITIES, registerEmptySurfaces } from './empty-surfaces.js';
|
|
10
12
|
import { registerWithMetrics } from './with-metrics.js';
|
|
11
13
|
import { CHANNEL_TOOLSET_INSTRUCTIONS, CHANNEL_TOOLSET_WAKE_INSTRUCTIONS, resolveToolset, toolInToolset } from './toolset.js';
|
|
12
14
|
import { createNudgeState } from './session-nudge.js';
|
|
@@ -73,6 +75,7 @@ import { checkToolConfig, makeCheckHandler, uncheckToolConfig, makeUncheckHandle
|
|
|
73
75
|
import { listUsersToolConfig, makeListUsersHandler, getAuditLogToolConfig, makeGetAuditLogHandler, triggerBackupToolConfig, makeTriggerBackupHandler, } from './tools/admin-writes.js';
|
|
74
76
|
import { freeBusyToolConfig, makeFreeBusyHandler } from './tools/free-busy.js';
|
|
75
77
|
import { createFullBackupToolConfig, makeCreateFullBackupHandler, listBackupsToolConfig, makeListBackupsHandler, downloadBackupToolConfig, makeDownloadBackupHandler, } from './tools/backup.js';
|
|
78
|
+
import { importNumberingToolConfig, makeImportNumberingHandler, importApplyToolConfig, makeImportApplyHandler, } from './tools/import-numbering.js';
|
|
76
79
|
import { primerFactListToolConfig, makePrimerFactListHandler, primerFactAddToolConfig, makePrimerFactAddHandler, primerFactUpdateToolConfig, makePrimerFactUpdateHandler, primerFactSupersedeToolConfig, makePrimerFactSupersedeHandler, primerFactVerifyToolConfig, makePrimerFactVerifyHandler, primerFactDeleteToolConfig, makePrimerFactDeleteHandler, } from './tools/primer-facts.js';
|
|
77
80
|
import { whoamiToolConfig, makeWhoamiHandler } from './tools/identity.js';
|
|
78
81
|
import { claimToolConfig, makeClaimHandler, unclaimToolConfig, makeUnclaimHandler, } from './tools/claim.js';
|
|
@@ -146,6 +149,8 @@ export function assembleInstructions(head, workingRules, budget = INSTRUCTIONS_B
|
|
|
146
149
|
return `${head}\n\n${RULES_HEADING}${kept}${TRUNCATION_MARKER}`;
|
|
147
150
|
}
|
|
148
151
|
export async function buildOrbotoMcpServer(opts) {
|
|
152
|
+
installZodCollectionBounds();
|
|
153
|
+
assertZodCollectionBounds();
|
|
149
154
|
const client = new OrbotoClient(opts);
|
|
150
155
|
const toolset = resolveToolset(opts.toolset, process.env.ORBOTO_MCP_TOOLSET);
|
|
151
156
|
let workingRules = FALLBACK_WORKING_RULES;
|
|
@@ -167,7 +172,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
167
172
|
const channelHint = opts.channel ? [CHANNEL_INSTRUCTIONS] : [];
|
|
168
173
|
const server = new McpServer({ name: 'orboto', version: VERSION }, {
|
|
169
174
|
capabilities: {
|
|
170
|
-
...(toolset === 'channel' ?
|
|
175
|
+
...(toolset === 'channel' ? EMPTY_SURFACE_CAPABILITIES : { resources: { subscribe: true, listChanged: true } }),
|
|
171
176
|
...(opts.channel ? { experimental: { [CHANNEL_CAPABILITY]: {} } } : {}),
|
|
172
177
|
},
|
|
173
178
|
instructions: toolset === 'channel'
|
|
@@ -328,6 +333,8 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
328
333
|
reg('orboto_create_full_backup', createFullBackupToolConfig, makeCreateFullBackupHandler(client));
|
|
329
334
|
reg('orboto_list_backups', listBackupsToolConfig, makeListBackupsHandler(client));
|
|
330
335
|
reg('orboto_download_backup', downloadBackupToolConfig, makeDownloadBackupHandler(client));
|
|
336
|
+
reg('orboto_import_numbering', importNumberingToolConfig, makeImportNumberingHandler(client));
|
|
337
|
+
reg('orboto_import_apply', importApplyToolConfig, makeImportApplyHandler(client));
|
|
331
338
|
reg('orboto_primer_fact_list', primerFactListToolConfig, makePrimerFactListHandler(client));
|
|
332
339
|
reg('orboto_primer_fact_add', primerFactAddToolConfig, makePrimerFactAddHandler(client));
|
|
333
340
|
reg('orboto_primer_fact_update', primerFactUpdateToolConfig, makePrimerFactUpdateHandler(client));
|
|
@@ -381,8 +388,10 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
381
388
|
reg('orboto_admin_translation_revert', revertTranslationToolConfig, makeAdminRevertTranslationHandler(client));
|
|
382
389
|
reg('orboto_admin_agent_drift_list', listAgentDriftToolConfig, makeListAgentDriftHandler(client));
|
|
383
390
|
reg('orboto_admin_agent_drift_resolve', resolveAgentDriftToolConfig, makeResolveAgentDriftHandler(client));
|
|
384
|
-
if (toolset === 'channel')
|
|
391
|
+
if (toolset === 'channel') {
|
|
392
|
+
registerEmptySurfaces(server);
|
|
385
393
|
return server;
|
|
394
|
+
}
|
|
386
395
|
registerOrbotoResources(server, client);
|
|
387
396
|
registerOrbotoPrompts(server);
|
|
388
397
|
if (opts.subscriptions) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { hermeticHome, hermeticSpawnEnv } from './hermetic-cli-env.js';
|
|
6
|
+
const mcpRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
7
|
+
/** ORB-2669 - the conversation a client such as Kimi Code opens with, over the real stdio entry: initialize, initialized, the three list methods without a capability check, one call of a tool that does not exist. */
|
|
8
|
+
export async function probeStdioServer(env, timeoutMs = 25_000) {
|
|
9
|
+
const api = createServer((req, res) => {
|
|
10
|
+
res.setHeader('Content-Type', 'application/json');
|
|
11
|
+
if (req.url?.startsWith('/system/mcp/status')) {
|
|
12
|
+
res.end(JSON.stringify({ enabled: true, mcpUseGranted: true, userMcpEnabled: true, userEmail: 'probe@example.test' }));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
res.statusCode = 404;
|
|
16
|
+
res.end('{}');
|
|
17
|
+
});
|
|
18
|
+
await new Promise((ok) => api.listen(0, '127.0.0.1', ok));
|
|
19
|
+
const address = api.address();
|
|
20
|
+
if (!address || typeof address === 'string')
|
|
21
|
+
throw new Error('probe API address missing');
|
|
22
|
+
const child = spawn(process.execPath, ['--import', 'tsx', 'src/index.ts'], {
|
|
23
|
+
cwd: mcpRoot,
|
|
24
|
+
env: hermeticSpawnEnv({
|
|
25
|
+
home: hermeticHome('mcp-stdio-probe'),
|
|
26
|
+
env: { ORBOTO_API_URL: `http://127.0.0.1:${address.port}`, ORBOTO_API_KEY: 'orb_probe', ORBOTO_AUTH: 'pat', ...env },
|
|
27
|
+
}),
|
|
28
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
const lines = [];
|
|
31
|
+
const answers = new Map();
|
|
32
|
+
const waiting = new Map();
|
|
33
|
+
let buffer = '', stderr = '';
|
|
34
|
+
child.stdout.on('data', (chunk) => {
|
|
35
|
+
buffer += chunk.toString();
|
|
36
|
+
for (let cut = buffer.indexOf('\n'); cut >= 0; cut = buffer.indexOf('\n')) {
|
|
37
|
+
const line = buffer.slice(0, cut).trim();
|
|
38
|
+
buffer = buffer.slice(cut + 1);
|
|
39
|
+
if (!line)
|
|
40
|
+
continue;
|
|
41
|
+
lines.push(line);
|
|
42
|
+
const message = JSON.parse(line);
|
|
43
|
+
if (typeof message.id === 'number') {
|
|
44
|
+
answers.set(message.id, { result: message.result, error: message.error });
|
|
45
|
+
waiting.get(message.id)?.();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
50
|
+
const exited = new Promise((_, no) => child.once('exit', (code) => no(new Error(`the MCP server exited with ${code} before it answered: ${stderr.slice(-600)}`))));
|
|
51
|
+
exited.catch(() => undefined);
|
|
52
|
+
const send = (message) => { child.stdin.write(`${JSON.stringify(message)}\n`); };
|
|
53
|
+
const answer = (id) => Promise.race([
|
|
54
|
+
new Promise((ok) => {
|
|
55
|
+
const have = answers.get(id);
|
|
56
|
+
if (have)
|
|
57
|
+
ok(have);
|
|
58
|
+
else
|
|
59
|
+
waiting.set(id, () => ok(answers.get(id)));
|
|
60
|
+
}),
|
|
61
|
+
exited,
|
|
62
|
+
new Promise((_, no) => setTimeout(() => no(new Error(`no answer to request ${id} in ${timeoutMs} ms: ${stderr.slice(-600)}`)), timeoutMs).unref()),
|
|
63
|
+
]);
|
|
64
|
+
try {
|
|
65
|
+
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'stdio-probe', version: 'probe' } } });
|
|
66
|
+
const initialize = await answer(1);
|
|
67
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
68
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
69
|
+
send({ jsonrpc: '2.0', id: 3, method: 'prompts/list' });
|
|
70
|
+
send({ jsonrpc: '2.0', id: 4, method: 'resources/list' });
|
|
71
|
+
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'orboto_no_such_tool', arguments: {} } });
|
|
72
|
+
const [tools, prompts, resources, unknownTool] = await Promise.all([answer(2), answer(3), answer(4), answer(5)]);
|
|
73
|
+
return { initialize, lists: { 'tools/list': tools, 'prompts/list': prompts, 'resources/list': resources }, unknownTool, lines };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
child.kill('SIGKILL');
|
|
77
|
+
await new Promise((ok) => api.close(() => ok()));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -17,7 +17,7 @@ export const agentHeartbeatToolConfig = {
|
|
|
17
17
|
scope: AgentSessionScopeSchema.optional(),
|
|
18
18
|
status: z.enum(['idle', 'working', 'blocked']).optional(),
|
|
19
19
|
workingOnTicketId: z.string().uuid().nullable().optional(),
|
|
20
|
-
capabilities: z.array(z.string()).optional(),
|
|
20
|
+
capabilities: z.array(z.string()).max(200).optional(),
|
|
21
21
|
clientInfo: z.object({
|
|
22
22
|
name: z.string().optional(),
|
|
23
23
|
version: z.string().optional(),
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
/** ORB-2136 - standalone mirror of `@orboto/shared-schema` agent-session-scope (the MCP package does not depend on it). */
|
|
3
3
|
export const AgentSessionRoleSchema = z.enum(['integrator', 'lead', 'worker', 'spec', 'review', 'coordinator']);
|
|
4
4
|
export const AgentSessionScopeSchema = z.object({
|
|
5
|
-
projectKeys: z.array(z.string()).optional(),
|
|
6
|
-
ticketKeys: z.array(z.string()).optional(),
|
|
5
|
+
projectKeys: z.array(z.string()).max(50).optional(),
|
|
6
|
+
ticketKeys: z.array(z.string()).max(200).optional(),
|
|
7
7
|
role: AgentSessionRoleSchema.optional(),
|
|
8
8
|
});
|
package/dist/tools/api-call.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* instead of surfacing as an opaque tool failure.
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
+
import { boundedRecord } from '../zod-bounds.js';
|
|
12
13
|
import { mcpInstanceToken } from './shared.js';
|
|
13
14
|
export const apiCallToolConfig = {
|
|
14
15
|
title: 'Call any orboto REST endpoint',
|
|
@@ -23,7 +24,7 @@ export const apiCallToolConfig = {
|
|
|
23
24
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
|
24
25
|
path: z.string().min(1).max(500)
|
|
25
26
|
.describe('Route path, e.g. "/projects/<uuid>/milestones". No query string.'),
|
|
26
|
-
query: z.
|
|
27
|
+
query: boundedRecord(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string()).max(200)]), 100).optional()
|
|
27
28
|
.describe('Query parameters as an object.'),
|
|
28
29
|
body: z.union([z.record(z.unknown()), z.array(z.unknown()), z.string(), z.number(), z.boolean()]).optional()
|
|
29
30
|
.describe('JSON body - a real object/array, not a stringified one.'),
|
|
@@ -14,8 +14,8 @@ const TicketDraftSchema = z.object({
|
|
|
14
14
|
priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional().describe('Default: normal.'),
|
|
15
15
|
deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional(),
|
|
16
16
|
milestone: z.string().optional().describe('Key ("ORB-M3"), name, or UUID; overrides the call-level one.'),
|
|
17
|
-
assigneeEmails: z.array(z.string().email()).optional(),
|
|
18
|
-
labels: z.array(z.string()).optional().describe('Existing label names.'),
|
|
17
|
+
assigneeEmails: z.array(z.string().email()).max(100).optional(),
|
|
18
|
+
labels: z.array(z.string()).max(100).optional().describe('Existing label names.'),
|
|
19
19
|
parentTicketKey: z.string().optional(),
|
|
20
20
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
21
21
|
isPrivate: z.boolean().optional(),
|
package/dist/tools/doc-edits.js
CHANGED
|
@@ -173,7 +173,7 @@ export const editDocSectionToolConfig = {
|
|
|
173
173
|
inputSchema: z.object({
|
|
174
174
|
docId: z.string().min(1).describe('Doc UUID or human-readable key (ORB-D12 / DOC-5). Find via orboto_search_docs.'),
|
|
175
175
|
sectionOps: z.array(z.object({
|
|
176
|
-
headingPath: z.array(z.string().min(1)).min(1).describe('Ordered heading breadcrumb, top-down. Exact + case-sensitive.'),
|
|
176
|
+
headingPath: z.array(z.string().min(1)).min(1).max(20).describe('Ordered heading breadcrumb, top-down. Exact + case-sensitive.'),
|
|
177
177
|
op: z.enum(['replace', 'append', 'insertAfter']).describe('replace body / append to body / insert directly below the heading line.'),
|
|
178
178
|
content: z.string().describe('New markdown content for the op.'),
|
|
179
179
|
})).min(1).max(100).describe('Applied sequentially after any string edits.'),
|
package/dist/tools/docs.js
CHANGED
|
@@ -154,7 +154,7 @@ export const updateDocSpaceToolConfig = {
|
|
|
154
154
|
isPublic: z.boolean().optional().describe('DEPRECATED - use guestsVisible.'),
|
|
155
155
|
visibility: z.enum(['workspace', 'project_members', 'restricted']).optional().describe('Read gate: workspace = every internal member, project_members = members with doc:view (project spaces), restricted = allowlist. Needs manage rights.'),
|
|
156
156
|
guestsVisible: z.boolean().optional().describe('Explicit guest (external user) opt-in. Requires manage rights.'),
|
|
157
|
-
memberIds: z.array(z.string().uuid()).optional().describe('Replaces the restricted-space allowlist. Requires manage rights.'),
|
|
157
|
+
memberIds: z.array(z.string().uuid()).max(5000).optional().describe('Replaces the restricted-space allowlist. Requires manage rights.'),
|
|
158
158
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
|
159
159
|
}).shape,
|
|
160
160
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
package/dist/tools/get-ticket.js
CHANGED
|
@@ -13,7 +13,7 @@ export const getTicketToolConfig = {
|
|
|
13
13
|
description: 'A ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, checklist progress, parent, open dependency edges and COUNTS of what is omitted (`include` opts blocks in). Comments: newest first, 3 newest in full, older shortened; `full` all, `commentId` one. Input: ticket key like "ACME-42".',
|
|
14
14
|
inputSchema: z.object({
|
|
15
15
|
ticketKey: z.string().min(3).describe('Key like "ACME-42".'),
|
|
16
|
-
include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems']))
|
|
16
|
+
include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems'])).max(20)
|
|
17
17
|
.optional()
|
|
18
18
|
.describe('Blocks to inline.'),
|
|
19
19
|
full: z.boolean().optional().describe('Comments in full.'),
|
|
@@ -95,6 +95,7 @@ export function makeGetTicketHandler(client) {
|
|
|
95
95
|
} : null,
|
|
96
96
|
childCount: children.length,
|
|
97
97
|
webUrl: full.webUrl ?? null,
|
|
98
|
+
...(full.sourceSystem || full.sourceKey ? { source: { system: full.sourceSystem ?? null, key: full.sourceKey ?? null, id: full.sourceId ?? null } } : {}),
|
|
98
99
|
blockedBy: {
|
|
99
100
|
openCount: full.blockedByOpenCount ?? 0,
|
|
100
101
|
tickets: (full.blockedByOpen ?? []).map(dependencyEdge),
|
|
@@ -221,6 +222,7 @@ function formatTicket(ticket, inc, comments, commentTotal, ticketKey, checklists
|
|
|
221
222
|
? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
|
|
222
223
|
: null,
|
|
223
224
|
ticket.branchName ? `Branch: ${ticket.branchName}` : null,
|
|
225
|
+
ticket.sourceSystem || ticket.sourceKey ? `Source: ${[ticket.sourceSystem, ticket.sourceKey].filter(Boolean).join(' ')}` : null,
|
|
224
226
|
ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
|
|
225
227
|
formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
|
|
226
228
|
formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2676 - import numbering: the plan of kept and renumbered source tickets and the apply with its double merge confirmation.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
6
|
+
const MERGE_NOT_CONFIRMED = 'errors.admin_import.merge_not_confirmed';
|
|
7
|
+
const NUMBERING_LIST_CAP = 30;
|
|
8
|
+
const TITLE_CHARS = 50;
|
|
9
|
+
const clip = (value, max) => (value.length > max ? `${value.slice(0, max - 3)}...` : value);
|
|
10
|
+
const moreLine = (shown, total, what) => total > shown ? [`... ${total - shown} more ${what} not listed (${total} in all)`] : [];
|
|
11
|
+
export function renumberedLine(r) {
|
|
12
|
+
const from = r.sourceKey ?? `#${r.sourceNumber}`;
|
|
13
|
+
const holder = r.reason === 'number_taken' && r.existingTicketKey
|
|
14
|
+
? `; ${r.existingTicketKey}${r.existingTicketTitle ? ` "${clip(r.existingTicketTitle, TITLE_CHARS)}"` : ''}`
|
|
15
|
+
: '';
|
|
16
|
+
return `${from} -> ${r.ticketKey} (${r.reason}${holder})`;
|
|
17
|
+
}
|
|
18
|
+
function renumberedPayload(rows) {
|
|
19
|
+
return rows.map((r) => ({
|
|
20
|
+
from: r.sourceKey ?? `#${r.sourceNumber}`,
|
|
21
|
+
to: r.ticketKey,
|
|
22
|
+
reason: r.reason,
|
|
23
|
+
...(r.existingTicketKey ? { existing: r.existingTicketKey } : {}),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function numberingResult(plan, token) {
|
|
27
|
+
const renumbered = Array.isArray(plan.renumbered) ? plan.renumbered : [];
|
|
28
|
+
const shown = renumbered.slice(0, NUMBERING_LIST_CAP);
|
|
29
|
+
const target = plan.projectKey ?? '(no key yet)';
|
|
30
|
+
const lines = [
|
|
31
|
+
`Import numbering for staged import ${token}: target ${target}${plan.sourceProjectKey ? `, source project ${plan.sourceProjectKey}` : ''}.`,
|
|
32
|
+
plan.merge
|
|
33
|
+
? `MERGE: ${target} already has ${plan.existingTickets} tickets. Applying needs confirmMerge: true AND confirmProjectKey: "${target}" typed by the user (orboto_import_apply).`
|
|
34
|
+
: 'Not a merge: the target has no tickets yet.',
|
|
35
|
+
`Keep their source number: ${plan.preserved}. No source number (fresh numbers after the highest): ${plan.withoutSourceNumber}. Renumbered: ${renumbered.length}. Next free number: ${plan.nextTicketNumber}.`,
|
|
36
|
+
...(plan.projectKeyIssue ? [`The source key ${plan.sourceProjectKey ?? ''} cannot become the project key: ${plan.projectKeyIssue === 'taken' ? 'another project holds it' : 'not 2-10 letters or digits'}.`] : []),
|
|
37
|
+
];
|
|
38
|
+
if (renumbered.length > 0) {
|
|
39
|
+
lines.push('Renumbered tickets:', ...shown.map(renumberedLine), ...moreLine(shown.length, renumbered.length, 'renumbered tickets'));
|
|
40
|
+
if (renumbered.length > shown.length)
|
|
41
|
+
lines.push(`The complete list: \`orboto import numbering ${token}\` in the CLI, or GET /admin/import/jobs/${token}/numbering through orboto_api_call.`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
45
|
+
structuredContent: {
|
|
46
|
+
projectKey: plan.projectKey,
|
|
47
|
+
sourceProjectKey: plan.sourceProjectKey,
|
|
48
|
+
projectKeyIssue: plan.projectKeyIssue,
|
|
49
|
+
merge: plan.merge,
|
|
50
|
+
existingTickets: plan.existingTickets,
|
|
51
|
+
preserved: plan.preserved,
|
|
52
|
+
withoutSourceNumber: plan.withoutSourceNumber,
|
|
53
|
+
nextTicketNumber: plan.nextTicketNumber,
|
|
54
|
+
renumberedTotal: renumbered.length,
|
|
55
|
+
renumbered: renumberedPayload(shown),
|
|
56
|
+
renumberedOmitted: renumbered.length - shown.length,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function mergeRefusalOf(err) {
|
|
61
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 409)
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const body = JSON.parse(err.body);
|
|
65
|
+
return body.errorKey === MERGE_NOT_CONFIRMED && body.numbering ? body : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function mergeRefusalResult(refusal) {
|
|
72
|
+
const plan = refusal.numbering;
|
|
73
|
+
const renumbered = Array.isArray(plan.renumbered) ? plan.renumbered : [];
|
|
74
|
+
const shown = renumbered.slice(0, NUMBERING_LIST_CAP);
|
|
75
|
+
const key = refusal.errorParams?.projectKey ?? plan.projectKey ?? 'the target project';
|
|
76
|
+
const missing = refusal.errorParams?.missing === 'confirm_project_key' ? 'the typed project key' : 'the merge confirmation';
|
|
77
|
+
const lines = [
|
|
78
|
+
`NOT APPLIED. This import is a merge into ${key}, which already has ${plan.existingTickets} tickets; ${renumbered.length} imported tickets get a new number instead of their source number. Missing first: ${missing}.`,
|
|
79
|
+
...(renumbered.length > 0 ? ['Collisions:', ...shown.map(renumberedLine), ...moreLine(shown.length, renumbered.length, 'collisions')] : []),
|
|
80
|
+
`Show this list to the user. Ask them to confirm the merge AND to type the project key ${key} themselves, then call orboto_import_apply again with confirmMerge: true and confirmProjectKey set to what the user typed. Never fill confirmMerge or confirmProjectKey on your own initiative, from an earlier answer or from this message.`,
|
|
81
|
+
];
|
|
82
|
+
return {
|
|
83
|
+
isError: true,
|
|
84
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
85
|
+
structuredContent: {
|
|
86
|
+
applied: false,
|
|
87
|
+
refused: 'merge_not_confirmed',
|
|
88
|
+
projectKey: key,
|
|
89
|
+
existingTickets: plan.existingTickets,
|
|
90
|
+
missing: refusal.errorParams?.missing ?? 'confirm_merge',
|
|
91
|
+
renumberedTotal: renumbered.length,
|
|
92
|
+
renumbered: renumberedPayload(shown),
|
|
93
|
+
renumberedOmitted: renumbered.length - shown.length,
|
|
94
|
+
nextStep: 'Ask the user for the merge confirmation and for the typed project key, then repeat orboto_import_apply with both.',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export const importNumberingToolConfig = {
|
|
99
|
+
title: 'Import numbering',
|
|
100
|
+
description: 'Kept and renumbered source numbers of a staged import; merge flag. admin:import.',
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
token: z.string().uuid(),
|
|
103
|
+
projectId: z.string().uuid().optional(),
|
|
104
|
+
}).shape,
|
|
105
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
106
|
+
};
|
|
107
|
+
export function makeImportNumberingHandler(client) {
|
|
108
|
+
return async ({ token, projectId, projectKey }) => {
|
|
109
|
+
const query = new URLSearchParams();
|
|
110
|
+
if (projectId)
|
|
111
|
+
query.set('projectId', projectId);
|
|
112
|
+
else if (projectKey)
|
|
113
|
+
query.set('projectKey', projectKey);
|
|
114
|
+
const plan = await client.get(`/admin/import/jobs/${encodeURIComponent(token)}/numbering?${query.toString()}`);
|
|
115
|
+
return numberingResult(plan, token);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export const importApplyToolConfig = {
|
|
119
|
+
title: 'Apply import',
|
|
120
|
+
description: 'Queue a staged import. Into a project with tickets: refused with the collisions until the user gives confirmMerge and confirmProjectKey; never fill them. config: source credentials. admin:import.',
|
|
121
|
+
inputSchema: z.object({
|
|
122
|
+
adapter: z.string().min(1).max(64),
|
|
123
|
+
stagingToken: z.string().uuid(),
|
|
124
|
+
mapping: z.record(z.unknown()),
|
|
125
|
+
projectKey: z.string().trim().max(10).optional(),
|
|
126
|
+
confirmMerge: z.boolean().optional(),
|
|
127
|
+
confirmProjectKey: z.string().trim().max(10).optional(),
|
|
128
|
+
config: z.record(z.unknown()).optional(),
|
|
129
|
+
}).shape,
|
|
130
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
131
|
+
};
|
|
132
|
+
export function makeImportApplyHandler(client) {
|
|
133
|
+
return async (args) => {
|
|
134
|
+
const { adapter, stagingToken, mapping, projectKey, confirmMerge, confirmProjectKey, config } = args;
|
|
135
|
+
try {
|
|
136
|
+
const job = await client.post(`/admin/import/${encodeURIComponent(adapter)}/apply-async`, {
|
|
137
|
+
stagingToken,
|
|
138
|
+
mapping,
|
|
139
|
+
...(projectKey ? { projectKey } : {}),
|
|
140
|
+
...(confirmMerge !== undefined ? { confirmMerge } : {}),
|
|
141
|
+
...(confirmProjectKey ? { confirmProjectKey } : {}),
|
|
142
|
+
...(config ? { config } : {}),
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: 'text', text: `Import ${job.status} (job ${job.jobId ?? 'none'}, token ${job.token}). Follow it with GET /admin/import/jobs/${job.token} through orboto_api_call; the finished result carries the numbering report.` }],
|
|
146
|
+
structuredContent: { applied: true, jobId: job.jobId, token: job.token, status: job.status },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const refusal = mergeRefusalOf(err);
|
|
151
|
+
if (refusal)
|
|
152
|
+
return mergeRefusalResult(refusal);
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2676 - orboto_import_numbering and orboto_import_apply.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
6
|
+
import { applyResponseBudget, budgetFor, measureHalves } from '../response-budget.js';
|
|
7
|
+
import { toolInToolset } from '../toolset.js';
|
|
8
|
+
import { makeImportApplyHandler, makeImportNumberingHandler, mergeRefusalOf, renumberedLine, } from './import-numbering.js';
|
|
9
|
+
const TOKEN = '3f2b6c1e-8a4d-4e5f-9b7a-1c2d3e4f5a6b';
|
|
10
|
+
const PROJECT_ID = '9b7a1c2d-3e4f-4a6b-8c1d-2e3f4a5b6c7d';
|
|
11
|
+
const textOf = (result) => result.content[0].text;
|
|
12
|
+
function ticket(n, reason = 'number_taken') {
|
|
13
|
+
return {
|
|
14
|
+
sourceId: `10${n}`,
|
|
15
|
+
sourceKey: `PROJ-${n}`,
|
|
16
|
+
sourceNumber: n,
|
|
17
|
+
reason,
|
|
18
|
+
existingTicketKey: reason === 'number_taken' ? `KEY-${n}` : null,
|
|
19
|
+
existingTicketTitle: reason === 'number_taken' ? `Existing ticket number ${n}` : null,
|
|
20
|
+
ticketNumber: 100 + n,
|
|
21
|
+
ticketKey: `KEY-${100 + n}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function plan(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
projectKey: 'KEY',
|
|
27
|
+
sourceProjectKey: 'PROJ',
|
|
28
|
+
projectKeyIssue: null,
|
|
29
|
+
merge: true,
|
|
30
|
+
existingTickets: 12,
|
|
31
|
+
preserved: 40,
|
|
32
|
+
withoutSourceNumber: 2,
|
|
33
|
+
renumbered: [ticket(2), ticket(5, 'duplicate_source_number')],
|
|
34
|
+
nextTicketNumber: 160,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function refusal(overrides = {}) {
|
|
39
|
+
const numbering = plan();
|
|
40
|
+
return {
|
|
41
|
+
error: 'This import merges into KEY',
|
|
42
|
+
errorKey: 'errors.admin_import.merge_not_confirmed',
|
|
43
|
+
errorParams: { projectKey: 'KEY', existingTickets: 12, renumbered: numbering.renumbered.length, missing: 'confirm_merge' },
|
|
44
|
+
numbering,
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const conflict = (body) => new OrbotoApiError(409, typeof body === 'string' ? body : JSON.stringify(body), 'https://orboto.example.com/admin/import/jira/apply-async');
|
|
49
|
+
describe('orboto_import_numbering', () => {
|
|
50
|
+
it('reads the numbering route with the project id and prints counts plus one line per renumbered ticket', async () => {
|
|
51
|
+
const get = vi.fn().mockResolvedValue(plan());
|
|
52
|
+
const result = await makeImportNumberingHandler({ get })({ token: TOKEN, projectId: PROJECT_ID });
|
|
53
|
+
expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?projectId=${PROJECT_ID}`);
|
|
54
|
+
const text = textOf(result);
|
|
55
|
+
expect(text).toContain('MERGE: KEY already has 12 tickets');
|
|
56
|
+
expect(text).toContain('confirmProjectKey: "KEY" typed by the user');
|
|
57
|
+
expect(text).toContain('Keep their source number: 40. No source number (fresh numbers after the highest): 2. Renumbered: 2. Next free number: 160.');
|
|
58
|
+
expect(text).toContain('PROJ-2 -> KEY-102 (number_taken; KEY-2 "Existing ticket number 2")');
|
|
59
|
+
expect(text).toContain('PROJ-5 -> KEY-105 (duplicate_source_number)');
|
|
60
|
+
expect(result.structuredContent).toMatchObject({
|
|
61
|
+
merge: true, existingTickets: 12, preserved: 40, renumberedTotal: 2, renumberedOmitted: 0,
|
|
62
|
+
renumbered: [{ from: 'PROJ-2', to: 'KEY-102', reason: 'number_taken', existing: 'KEY-2' }, { from: 'PROJ-5', to: 'KEY-105', reason: 'duplicate_source_number' }],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it('sends the proposed key of a new project, says it is no merge and names an unusable source key', async () => {
|
|
66
|
+
const get = vi.fn().mockResolvedValue(plan({ merge: false, existingTickets: 0, renumbered: [], projectKey: 'NEWK', projectKeyIssue: 'taken' }));
|
|
67
|
+
const result = await makeImportNumberingHandler({ get })({ token: TOKEN, projectKey: 'NEWK' });
|
|
68
|
+
expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?projectKey=NEWK`);
|
|
69
|
+
expect(textOf(result)).toContain('Not a merge');
|
|
70
|
+
expect(textOf(result)).toContain('cannot become the project key: another project holds it');
|
|
71
|
+
expect(textOf(result)).not.toContain('Renumbered tickets');
|
|
72
|
+
});
|
|
73
|
+
it('falls back to a source number line for a ticket without a source key', () => {
|
|
74
|
+
expect(renumberedLine({ ...ticket(7), sourceKey: null })).toBe('#7 -> KEY-107 (number_taken; KEY-7 "Existing ticket number 7")');
|
|
75
|
+
});
|
|
76
|
+
it('lists the first 30 renumbered tickets and says explicitly how many it left out, inside the default budget', async () => {
|
|
77
|
+
const many = Array.from({ length: 2000 }, (_, i) => ticket(i + 1));
|
|
78
|
+
const get = vi.fn().mockResolvedValue(plan({ renumbered: many }));
|
|
79
|
+
const result = await makeImportNumberingHandler({ get })({ token: TOKEN });
|
|
80
|
+
expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?`);
|
|
81
|
+
expect(textOf(result)).toContain('... 1970 more renumbered tickets not listed (2000 in all)');
|
|
82
|
+
expect(textOf(result)).toContain(`orboto import numbering ${TOKEN}`);
|
|
83
|
+
expect(result.structuredContent).toMatchObject({ renumberedTotal: 2000, renumberedOmitted: 1970 });
|
|
84
|
+
expect(result.structuredContent.renumbered).toHaveLength(30);
|
|
85
|
+
const { textChars, structuredChars } = measureHalves(result);
|
|
86
|
+
expect(Math.max(textChars, structuredChars)).toBeLessThanOrEqual(budgetFor('orboto_import_numbering', {}));
|
|
87
|
+
const budgeted = applyResponseBudget('orboto_import_numbering', result, {});
|
|
88
|
+
expect(budgeted.truncatedChars).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it('is cut explicitly by the central budget when the answer is still too long', () => {
|
|
91
|
+
const long = Array.from({ length: 30 }, (_, i) => ({ ...ticket(i + 1), sourceKey: `PROJ-${'K'.repeat(400)}-${i}` }));
|
|
92
|
+
const get = vi.fn().mockResolvedValue(plan({ renumbered: long }));
|
|
93
|
+
return makeImportNumberingHandler({ get })({ token: TOKEN }).then((result) => {
|
|
94
|
+
const budgeted = applyResponseBudget('orboto_import_numbering', result, {});
|
|
95
|
+
expect(budgeted.responseChars).toBeLessThanOrEqual(budgetFor('orboto_import_numbering', {}) + 700);
|
|
96
|
+
expect(budgeted.handle).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('orboto_import_apply', () => {
|
|
101
|
+
const mapping = { projectId: PROJECT_ID, statusMap: { Open: 'todo' } };
|
|
102
|
+
it('queues the apply with the confirmations and the credentials only when given', async () => {
|
|
103
|
+
const post = vi.fn().mockResolvedValue({ jobId: 'job-1', token: TOKEN, status: 'queued' });
|
|
104
|
+
const handler = makeImportApplyHandler({ post });
|
|
105
|
+
const plain = await handler({ adapter: 'jira', stagingToken: TOKEN, mapping });
|
|
106
|
+
expect(post).toHaveBeenLastCalledWith('/admin/import/jira/apply-async', { stagingToken: TOKEN, mapping });
|
|
107
|
+
expect(textOf(plain)).toContain(`Import queued (job job-1, token ${TOKEN}`);
|
|
108
|
+
expect(plain.structuredContent).toEqual({ applied: true, jobId: 'job-1', token: TOKEN, status: 'queued' });
|
|
109
|
+
expect(plain.isError).toBeUndefined();
|
|
110
|
+
await handler({
|
|
111
|
+
adapter: 'jira', stagingToken: TOKEN, mapping, projectKey: 'NEWK', confirmMerge: true, confirmProjectKey: 'KEY', config: { baseUrl: 'https://jira.example.com', apiToken: 'secret' },
|
|
112
|
+
});
|
|
113
|
+
expect(post).toHaveBeenLastCalledWith('/admin/import/jira/apply-async', {
|
|
114
|
+
stagingToken: TOKEN, mapping, projectKey: 'NEWK', confirmMerge: true, confirmProjectKey: 'KEY', config: { baseUrl: 'https://jira.example.com', apiToken: 'secret' },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
it('turns the 409 merge refusal into an error result that lists the collisions and asks the user for both confirmations', async () => {
|
|
118
|
+
const post = vi.fn().mockRejectedValue(conflict(refusal()));
|
|
119
|
+
const result = await makeImportApplyHandler({ post })({ adapter: 'jira', stagingToken: TOKEN, mapping });
|
|
120
|
+
expect(result.isError).toBe(true);
|
|
121
|
+
const text = textOf(result);
|
|
122
|
+
expect(text).toContain('NOT APPLIED');
|
|
123
|
+
expect(text).toContain('merge into KEY, which already has 12 tickets; 2 imported tickets get a new number');
|
|
124
|
+
expect(text).toContain('Missing first: the merge confirmation');
|
|
125
|
+
expect(text).toContain('PROJ-2 -> KEY-102 (number_taken; KEY-2 "Existing ticket number 2")');
|
|
126
|
+
expect(text).toContain('Ask them to confirm the merge AND to type the project key KEY themselves');
|
|
127
|
+
expect(text).toContain('Never fill confirmMerge or confirmProjectKey on your own initiative');
|
|
128
|
+
expect(result.structuredContent).toMatchObject({
|
|
129
|
+
applied: false, refused: 'merge_not_confirmed', projectKey: 'KEY', existingTickets: 12, missing: 'confirm_merge', renumberedTotal: 2,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('names the typed project key as the missing confirmation when the merge was confirmed alone', async () => {
|
|
133
|
+
const body = refusal({ errorParams: { projectKey: 'KEY', existingTickets: 12, renumbered: 2, missing: 'confirm_project_key' } });
|
|
134
|
+
const post = vi.fn().mockRejectedValue(conflict(body));
|
|
135
|
+
const result = await makeImportApplyHandler({ post })({ adapter: 'jira', stagingToken: TOKEN, mapping, confirmMerge: true });
|
|
136
|
+
expect(textOf(result)).toContain('Missing first: the typed project key');
|
|
137
|
+
expect(result.structuredContent).toMatchObject({ missing: 'confirm_project_key' });
|
|
138
|
+
});
|
|
139
|
+
it('rethrows every other failure, including a 409 that is not the merge refusal', async () => {
|
|
140
|
+
const handler = (err) => makeImportApplyHandler({ post: vi.fn().mockRejectedValue(err) })({ adapter: 'jira', stagingToken: TOKEN, mapping });
|
|
141
|
+
const forbidden = new OrbotoApiError(403, '{"error":"no"}', 'u');
|
|
142
|
+
await expect(handler(forbidden)).rejects.toBe(forbidden);
|
|
143
|
+
const other = conflict({ error: 'busy', errorKey: 'errors.system.import_already_running' });
|
|
144
|
+
await expect(handler(other)).rejects.toBe(other);
|
|
145
|
+
const garbled = conflict('not json');
|
|
146
|
+
await expect(handler(garbled)).rejects.toBe(garbled);
|
|
147
|
+
const plain = new Error('network');
|
|
148
|
+
await expect(handler(plain)).rejects.toBe(plain);
|
|
149
|
+
});
|
|
150
|
+
it('recognises only the merge refusal with a numbering plan', () => {
|
|
151
|
+
expect(mergeRefusalOf(conflict(refusal()))?.errorParams?.projectKey).toBe('KEY');
|
|
152
|
+
expect(mergeRefusalOf(conflict({ errorKey: 'errors.admin_import.merge_not_confirmed' }))).toBeNull();
|
|
153
|
+
expect(mergeRefusalOf(new OrbotoApiError(400, JSON.stringify(refusal()), 'u'))).toBeNull();
|
|
154
|
+
expect(mergeRefusalOf(new Error('x'))).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe('toolsets', () => {
|
|
158
|
+
it('keeps the two import tools out of the curated, minimal and channel sets', () => {
|
|
159
|
+
for (const name of ['orboto_import_numbering', 'orboto_import_apply']) {
|
|
160
|
+
expect(toolInToolset(name, 'full')).toBe(true);
|
|
161
|
+
expect(toolInToolset(name, 'curated')).toBe(false);
|
|
162
|
+
expect(toolInToolset(name, 'minimal')).toBe(false);
|
|
163
|
+
expect(toolInToolset(name, 'channel')).toBe(false);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -323,6 +323,29 @@ describe('orboto_get_ticket', () => {
|
|
|
323
323
|
const sc = res.structuredContent;
|
|
324
324
|
expect(sc.waitingForGitIngestion).toBe(true);
|
|
325
325
|
});
|
|
326
|
+
it('ORB-2676: an imported ticket shows its source system and key; a native ticket shows no Source line', async () => {
|
|
327
|
+
const row = { id: 't1', projectId: 'p1', ticketKey: 'ACME-280', title: 'Imported', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'task', priority: 'normal' };
|
|
328
|
+
stub([
|
|
329
|
+
{ json: PROJ },
|
|
330
|
+
{ json: { ...row, gitActivityCount: 0, parentTicketId: null } },
|
|
331
|
+
{ json: { ...row, sourceSystem: 'jira', sourceKey: 'PROJ-280', sourceId: '10280' } },
|
|
332
|
+
NO_CHILDREN,
|
|
333
|
+
{ json: [] },
|
|
334
|
+
]);
|
|
335
|
+
const imported = await makeGetTicketHandler(client)({ ticketKey: 'ACME-280' });
|
|
336
|
+
expect(imported.content[0].text).toContain('Source: jira PROJ-280');
|
|
337
|
+
expect(imported.structuredContent.source).toEqual({ system: 'jira', key: 'PROJ-280', id: '10280' });
|
|
338
|
+
stub([
|
|
339
|
+
{ json: PROJ },
|
|
340
|
+
{ json: { ...row, gitActivityCount: 0, parentTicketId: null } },
|
|
341
|
+
{ json: { ...row, sourceSystem: null, sourceKey: null, sourceId: null } },
|
|
342
|
+
NO_CHILDREN,
|
|
343
|
+
{ json: [] },
|
|
344
|
+
]);
|
|
345
|
+
const native = await makeGetTicketHandler(client)({ ticketKey: 'ACME-280' });
|
|
346
|
+
expect(native.content[0].text).not.toContain('Source:');
|
|
347
|
+
expect(native.structuredContent.source).toBeUndefined();
|
|
348
|
+
});
|
|
326
349
|
it('include: ["checklistItems"] reads effectiveCompleted (not done) + surfaces linked-ticket suffix', async () => {
|
|
327
350
|
stub([
|
|
328
351
|
{ json: PROJ },
|
package/dist/tools/search.js
CHANGED
|
@@ -11,7 +11,7 @@ export const searchToolConfig = {
|
|
|
11
11
|
description: 'Search tickets, comments and docs; `types` adds projects, milestones, spaces, customers. Content matches full text, jump types match key and name; `types` defaults to ticket, comment, doc. Honours the caller\'s visibility - private tickets and internal comments never appear unless the caller can already see them. Recall (ORB-1695): when the strict all-terms pass finds nothing, the server automatically retries with an OR-relaxed pass plus semantic (embedding) recall - the response\'s `pass` field says which pass produced the hits, and relaxed-pass hits deserve a skeptical read (they matched SOME terms or only the meaning, not all terms). Still prefer a single distinctive STABLE token (file/component/error-string fragment like "AdminCodesPage") and search the SYMPTOM, not your intended fix.',
|
|
12
12
|
inputSchema: z.object({
|
|
13
13
|
query: z.string().min(1).describe('Search terms, e.g. "queue worker retry".'),
|
|
14
|
-
types: z.array(z.enum(SEARCH_TYPES)).optional(),
|
|
14
|
+
types: z.array(z.enum(SEARCH_TYPES)).max(20).optional(),
|
|
15
15
|
projectKey: z.string().optional().describe('Restrict to one project.'),
|
|
16
16
|
limit: z.number().int().min(1).max(50).default(15),
|
|
17
17
|
}).shape,
|
|
@@ -4,7 +4,7 @@ export const TicketSpecStateSchema = z.enum(['none', 'needs_spec', 'in_spec', 'r
|
|
|
4
4
|
export const TicketListSortSchema = z.enum(['updated', 'created']);
|
|
5
5
|
export const ProjectSpecSettingsSchema = z.object({
|
|
6
6
|
enabled: z.boolean().optional(),
|
|
7
|
-
requiredTypes: z.array(z.enum(['epic', 'story', 'task', 'bug'])).optional(),
|
|
7
|
+
requiredTypes: z.array(z.enum(['epic', 'story', 'task', 'bug'])).max(20).optional(),
|
|
8
8
|
releaseBy: z.enum(['agent', 'author']).optional(),
|
|
9
9
|
}).strict();
|
|
10
10
|
export function specGateFailure(body) {
|
|
@@ -51,8 +51,8 @@ export const scheduleTicketSessionToolConfig = {
|
|
|
51
51
|
title: z.string().optional(),
|
|
52
52
|
location: z.string().optional().describe('Free text - Zoom URL, room name, "remote".'),
|
|
53
53
|
notes: z.string().optional(),
|
|
54
|
-
attendeeUserIds: z.array(z.string().uuid()).optional().describe('orboto attendee user UUIDs.'),
|
|
55
|
-
externalEmails: z.array(z.string().email()).optional().describe('External (non-orboto) guest emails.'),
|
|
54
|
+
attendeeUserIds: z.array(z.string().uuid()).max(500).optional().describe('orboto attendee user UUIDs.'),
|
|
55
|
+
externalEmails: z.array(z.string().email()).max(500).optional().describe('External (non-orboto) guest emails.'),
|
|
56
56
|
}).shape,
|
|
57
57
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
58
58
|
};
|
|
@@ -67,8 +67,8 @@ export const createTicketToolConfig = {
|
|
|
67
67
|
specState: TicketSpecStateSchema.optional(),
|
|
68
68
|
deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional(),
|
|
69
69
|
milestone: z.string().optional().describe('Key ("ORB-M3"), name, or UUID.'),
|
|
70
|
-
assigneeEmails: z.array(z.string().email()).optional().describe('Project-member emails.'),
|
|
71
|
-
labels: z.array(z.string()).optional(),
|
|
70
|
+
assigneeEmails: z.array(z.string().email()).max(100).optional().describe('Project-member emails.'),
|
|
71
|
+
labels: z.array(z.string()).max(100).optional(),
|
|
72
72
|
parentTicketKey: z.string().optional().describe('Parent key - makes this a sub-ticket.'),
|
|
73
73
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
74
74
|
isPrivate: z.boolean().optional(),
|
|
@@ -504,7 +504,7 @@ export const workSessionClaimsReleaseToolConfig = {
|
|
|
504
504
|
description: 'Drop resource claims (or all) from your session without finishing it or touching lease and timer. Releasing a granted write claim immediately runs a grant pass, so the earliest queued waiter for that resource is promoted as part of this call - useful once you know you are done touching a subtree but are not done with the ticket. Omit `claims` to release everything the session holds.',
|
|
505
505
|
inputSchema: z.object({
|
|
506
506
|
sessionId: z.string().uuid().describe('The id returned by orboto_work_session_start.'),
|
|
507
|
-
claims: z.array(z.object({ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500) })).optional()
|
|
507
|
+
claims: z.array(z.object({ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500) })).max(500).optional()
|
|
508
508
|
.describe('Which claims to release, matched by kind+value. Omit to release ALL claims on this session.'),
|
|
509
509
|
}).shape,
|
|
510
510
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2669 - every toolset answers tools/list, prompts/list and resources/list
|
|
3
|
+
* over the real stdio entry and declares the matching capabilities. Kimi Code
|
|
4
|
+
* lists without checking capabilities and dropped the `channel` server on -32601.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { CHANNEL_CAPABILITY } from './inbox-channel.js';
|
|
8
|
+
import { CHANNEL_TOOLSET_INSTRUCTIONS, CHANNEL_TOOLSET_WAKE_INSTRUCTIONS } from './toolset.js';
|
|
9
|
+
import { probeStdioServer } from './test/stdio-probe.js';
|
|
10
|
+
const PROBE_TIMEOUT_MS = 60_000;
|
|
11
|
+
function listed(answer, key) {
|
|
12
|
+
const value = answer.result?.[key];
|
|
13
|
+
expect(Array.isArray(value), `${key} is a list`).toBe(true);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
describe('ORB-2669 - the list methods over the stdio entry', () => {
|
|
17
|
+
it.each(['channel', 'minimal', 'curated', 'full'])('%s declares tools, prompts and resources and answers all three list methods', async (toolset) => {
|
|
18
|
+
const probe = await probeStdioServer({ ORBOTO_MCP_TOOLSET: toolset });
|
|
19
|
+
for (const method of ['tools/list', 'prompts/list', 'resources/list']) {
|
|
20
|
+
expect(probe.lists[method].error, `${toolset} ${method}`).toBeUndefined();
|
|
21
|
+
}
|
|
22
|
+
const capabilities = probe.initialize.result?.capabilities;
|
|
23
|
+
expect(capabilities).toHaveProperty('tools');
|
|
24
|
+
expect(capabilities).toHaveProperty('prompts');
|
|
25
|
+
expect(capabilities).toHaveProperty('resources');
|
|
26
|
+
const tools = listed(probe.lists['tools/list'], 'tools');
|
|
27
|
+
const prompts = listed(probe.lists['prompts/list'], 'prompts');
|
|
28
|
+
const resources = listed(probe.lists['resources/list'], 'resources');
|
|
29
|
+
if (toolset === 'channel') {
|
|
30
|
+
expect([tools.length, prompts.length, resources.length]).toEqual([0, 0, 0]);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
34
|
+
expect(prompts.length).toBeGreaterThan(0);
|
|
35
|
+
}
|
|
36
|
+
expect(probe.unknownTool.error).toBeUndefined();
|
|
37
|
+
expect(probe.unknownTool.result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'MCP error -32602: Tool orboto_no_such_tool not found' }] });
|
|
38
|
+
}, PROBE_TIMEOUT_MS);
|
|
39
|
+
it('channel keeps the wake capability and its CLI-first instructions next to the empty lists', async () => {
|
|
40
|
+
const probe = await probeStdioServer({ ORBOTO_MCP_TOOLSET: 'channel' });
|
|
41
|
+
expect(probe.initialize.result?.capabilities).toMatchObject({ experimental: { [CHANNEL_CAPABILITY]: {} }, tools: {}, prompts: {}, resources: {} });
|
|
42
|
+
expect(probe.initialize.result?.instructions).toBe([CHANNEL_TOOLSET_INSTRUCTIONS, CHANNEL_TOOLSET_WAKE_INSTRUCTIONS].join('\n\n'));
|
|
43
|
+
}, PROBE_TIMEOUT_MS);
|
|
44
|
+
it('the channel start can be switched off and the lists still answer', async () => {
|
|
45
|
+
const probe = await probeStdioServer({ ORBOTO_MCP_TOOLSET: 'channel', ORBOTO_MCP_CHANNEL: '0' });
|
|
46
|
+
for (const method of ['tools/list', 'prompts/list', 'resources/list']) {
|
|
47
|
+
expect(probe.lists[method].error, method).toBeUndefined();
|
|
48
|
+
}
|
|
49
|
+
const capabilities = probe.initialize.result?.capabilities;
|
|
50
|
+
expect(capabilities).not.toHaveProperty('experimental');
|
|
51
|
+
expect(capabilities).toHaveProperty('tools');
|
|
52
|
+
}, PROBE_TIMEOUT_MS);
|
|
53
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** ORB-2670 - GHSA-mrhx-c7xc-m3w5: an array over its `.max()` / `.length()` or a `boundedRecord` over its key limit fails with one issue before any element is parsed. */
|
|
2
|
+
import * as zod from 'zod';
|
|
3
|
+
const INSTALLED = Symbol.for('orboto.zodCollectionBounds');
|
|
4
|
+
function exceedsKeyLimit(data, maxKeys) {
|
|
5
|
+
return !!data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length > maxKeys;
|
|
6
|
+
}
|
|
7
|
+
/** Patches one zod module instance; a CommonJS program that also loads this ESM module passes its own `zod` too. */
|
|
8
|
+
export function installZodCollectionBounds(runtime = zod) {
|
|
9
|
+
const { INVALID, ZodArray, ZodIssueCode, ZodRecord, addIssueToContext } = runtime;
|
|
10
|
+
const arrayProto = ZodArray.prototype;
|
|
11
|
+
if (arrayProto[INSTALLED])
|
|
12
|
+
return;
|
|
13
|
+
const parseArray = arrayProto._parse;
|
|
14
|
+
arrayProto._parse = function boundedArrayParse(input) {
|
|
15
|
+
const limit = this._def.exactLength ?? this._def.maxLength;
|
|
16
|
+
if (limit && Array.isArray(input.data) && input.data.length > limit.value) {
|
|
17
|
+
addIssueToContext(this._getOrReturnCtx(input), {
|
|
18
|
+
code: ZodIssueCode.too_big,
|
|
19
|
+
maximum: limit.value,
|
|
20
|
+
type: 'array',
|
|
21
|
+
inclusive: true,
|
|
22
|
+
exact: limit === this._def.exactLength,
|
|
23
|
+
message: limit.message,
|
|
24
|
+
});
|
|
25
|
+
return INVALID;
|
|
26
|
+
}
|
|
27
|
+
return parseArray.call(this, input);
|
|
28
|
+
};
|
|
29
|
+
arrayProto[INSTALLED] = true;
|
|
30
|
+
const recordProto = ZodRecord.prototype;
|
|
31
|
+
const parseRecord = recordProto._parse;
|
|
32
|
+
recordProto._parse = function boundedRecordParse(input) {
|
|
33
|
+
const maxKeys = this._def.maxKeys;
|
|
34
|
+
if (maxKeys !== undefined && exceedsKeyLimit(input.data, maxKeys)) {
|
|
35
|
+
addIssueToContext(this._getOrReturnCtx(input), {
|
|
36
|
+
code: ZodIssueCode.custom,
|
|
37
|
+
message: `Object must contain at most ${maxKeys} key(s)`,
|
|
38
|
+
params: { maxKeys },
|
|
39
|
+
});
|
|
40
|
+
return INVALID;
|
|
41
|
+
}
|
|
42
|
+
return parseRecord.call(this, input);
|
|
43
|
+
};
|
|
44
|
+
recordProto[INSTALLED] = true;
|
|
45
|
+
}
|
|
46
|
+
export function zodCollectionBoundsInstalled(runtime = zod) {
|
|
47
|
+
const marked = (proto) => proto[INSTALLED] === true;
|
|
48
|
+
return marked(runtime.ZodArray.prototype) && marked(runtime.ZodRecord.prototype);
|
|
49
|
+
}
|
|
50
|
+
/** Throws unless this module's zod and every passed instance carry the installed bounds. */
|
|
51
|
+
export function assertZodCollectionBounds(...runtimes) {
|
|
52
|
+
if ([zod, ...runtimes].every((runtime) => zodCollectionBoundsInstalled(runtime)))
|
|
53
|
+
return;
|
|
54
|
+
throw new Error('zod collection bounds are not installed: call installZodCollectionBounds() for the ESM zod and installZodCollectionBounds(zod) for a CommonJS one before routes or tools register (ORB-2670, GHSA-mrhx-c7xc-m3w5).');
|
|
55
|
+
}
|
|
56
|
+
export function boundedRecord(keySchema, valueSchema, maxKeys) {
|
|
57
|
+
const base = zod.ZodRecord.create(keySchema, valueSchema);
|
|
58
|
+
return new zod.ZodRecord({ ...base._def, maxKeys });
|
|
59
|
+
}
|
|
60
|
+
function defOf(schema) {
|
|
61
|
+
return (schema?._def ?? {});
|
|
62
|
+
}
|
|
63
|
+
function isPassthrough(schema) {
|
|
64
|
+
const typeName = defOf(schema).typeName;
|
|
65
|
+
return typeName === 'ZodUnknown' || typeName === 'ZodAny';
|
|
66
|
+
}
|
|
67
|
+
function entriesCanFail(def) {
|
|
68
|
+
const key = defOf(def.keyType);
|
|
69
|
+
const plainKey = key.typeName === 'ZodString' && key.checks?.length === 0;
|
|
70
|
+
return !(isPassthrough(def.valueType) && plainKey);
|
|
71
|
+
}
|
|
72
|
+
/** Paths of every array, record, catchall, set, map and tuple rest whose unbounded entries can each fail. */
|
|
73
|
+
export function findUnboundedCollections(schema, path = '$') {
|
|
74
|
+
const found = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const walk = (node, at) => {
|
|
77
|
+
if (!node || typeof node !== 'object' || seen.has(node))
|
|
78
|
+
return;
|
|
79
|
+
seen.add(node);
|
|
80
|
+
const def = defOf(node);
|
|
81
|
+
switch (def.typeName) {
|
|
82
|
+
case 'ZodArray':
|
|
83
|
+
if (!def.maxLength && !def.exactLength && !isPassthrough(def.type))
|
|
84
|
+
found.push(at);
|
|
85
|
+
walk(def.type, `${at}[]`);
|
|
86
|
+
return;
|
|
87
|
+
case 'ZodRecord':
|
|
88
|
+
if (def.maxKeys === undefined && entriesCanFail(def))
|
|
89
|
+
found.push(at);
|
|
90
|
+
walk(def.keyType, `${at}{key}`);
|
|
91
|
+
walk(def.valueType, `${at}{}`);
|
|
92
|
+
return;
|
|
93
|
+
case 'ZodSet':
|
|
94
|
+
if (!def.maxSize)
|
|
95
|
+
found.push(at);
|
|
96
|
+
walk(def.valueType, `${at}[]`);
|
|
97
|
+
return;
|
|
98
|
+
case 'ZodMap':
|
|
99
|
+
found.push(at);
|
|
100
|
+
walk(def.keyType, `${at}{key}`);
|
|
101
|
+
walk(def.valueType, `${at}{}`);
|
|
102
|
+
return;
|
|
103
|
+
case 'ZodTuple':
|
|
104
|
+
def.items.forEach((item, i) => walk(item, `${at}[${i}]`));
|
|
105
|
+
if (def.rest) {
|
|
106
|
+
found.push(`${at}[...]`);
|
|
107
|
+
walk(def.rest, `${at}[...]`);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
case 'ZodObject': {
|
|
111
|
+
const shape = def.shape();
|
|
112
|
+
for (const [key, child] of Object.entries(shape))
|
|
113
|
+
walk(child, `${at}.${key}`);
|
|
114
|
+
if (defOf(def.catchall).typeName !== 'ZodNever') {
|
|
115
|
+
if (!isPassthrough(def.catchall))
|
|
116
|
+
found.push(`${at}.*`);
|
|
117
|
+
walk(def.catchall, `${at}.*`);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
case 'ZodUnion':
|
|
122
|
+
case 'ZodDiscriminatedUnion':
|
|
123
|
+
for (const option of def.options)
|
|
124
|
+
walk(option, at);
|
|
125
|
+
return;
|
|
126
|
+
case 'ZodIntersection':
|
|
127
|
+
walk(def.left, at);
|
|
128
|
+
walk(def.right, at);
|
|
129
|
+
return;
|
|
130
|
+
case 'ZodLazy':
|
|
131
|
+
walk(def.getter(), at);
|
|
132
|
+
return;
|
|
133
|
+
case 'ZodEffects':
|
|
134
|
+
walk(def.schema, at);
|
|
135
|
+
return;
|
|
136
|
+
case 'ZodPipeline':
|
|
137
|
+
walk(def.in, at);
|
|
138
|
+
walk(def.out, at);
|
|
139
|
+
return;
|
|
140
|
+
case 'ZodBranded':
|
|
141
|
+
walk(def.type, at);
|
|
142
|
+
return;
|
|
143
|
+
case 'ZodOptional':
|
|
144
|
+
case 'ZodNullable':
|
|
145
|
+
case 'ZodDefault':
|
|
146
|
+
case 'ZodCatch':
|
|
147
|
+
case 'ZodReadonly':
|
|
148
|
+
case 'ZodPromise':
|
|
149
|
+
walk(def.innerType ?? def.type, at);
|
|
150
|
+
return;
|
|
151
|
+
default:
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(schema, path);
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2670 - every MCP tool input is length-bounded and the server installs the zod v3 short-circuit (GHSA-mrhx-c7xc-m3w5).
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
7
|
+
import { assertZodCollectionBounds, findUnboundedCollections, zodCollectionBoundsInstalled } from './zod-bounds.js';
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
|
|
11
|
+
delete process.env.ORBOTO_MCP_TOOLSET;
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
describe('ORB-2670 - MCP tool inputs are bounded', () => {
|
|
15
|
+
it('no tool of the full toolset takes an unbounded array, record, catchall, set or map', async () => {
|
|
16
|
+
const server = await buildOrbotoMcpServer({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full' });
|
|
17
|
+
const tools = server._registeredTools;
|
|
18
|
+
expect(Object.keys(tools).length).toBeGreaterThan(150);
|
|
19
|
+
const found = Object.entries(tools).flatMap(([name, tool]) => findUnboundedCollections(tool.inputSchema, name));
|
|
20
|
+
expect(found).toEqual([]);
|
|
21
|
+
await server.close();
|
|
22
|
+
});
|
|
23
|
+
it('building the server installs the short-circuit: an oversized array yields one issue', async () => {
|
|
24
|
+
const server = await buildOrbotoMcpServer({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'minimal' });
|
|
25
|
+
expect(zodCollectionBoundsInstalled()).toBe(true);
|
|
26
|
+
const result = await z.object({ ticketKeys: z.array(z.string()).max(100) }).safeParseAsync({ ticketKeys: Array.from({ length: 200_000 }, () => 0) });
|
|
27
|
+
expect(result.error?.issues).toEqual([expect.objectContaining({ code: 'too_big', maximum: 100 })]);
|
|
28
|
+
await server.close();
|
|
29
|
+
});
|
|
30
|
+
it('the guard refuses a zod instance without the bounds', () => {
|
|
31
|
+
class UnpatchedArray {
|
|
32
|
+
}
|
|
33
|
+
class UnpatchedRecord {
|
|
34
|
+
}
|
|
35
|
+
const unpatched = { ZodArray: UnpatchedArray, ZodRecord: UnpatchedRecord };
|
|
36
|
+
expect(() => assertZodCollectionBounds(unpatched)).toThrow(/zod collection bounds are not installed/);
|
|
37
|
+
expect(() => assertZodCollectionBounds()).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.230.
|
|
3
|
+
"version": "0.230.9",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|