@orboto/mcp 0.230.6 → 0.230.8
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 +8 -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 +1 -1
- 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';
|
|
@@ -146,6 +148,8 @@ export function assembleInstructions(head, workingRules, budget = INSTRUCTIONS_B
|
|
|
146
148
|
return `${head}\n\n${RULES_HEADING}${kept}${TRUNCATION_MARKER}`;
|
|
147
149
|
}
|
|
148
150
|
export async function buildOrbotoMcpServer(opts) {
|
|
151
|
+
installZodCollectionBounds();
|
|
152
|
+
assertZodCollectionBounds();
|
|
149
153
|
const client = new OrbotoClient(opts);
|
|
150
154
|
const toolset = resolveToolset(opts.toolset, process.env.ORBOTO_MCP_TOOLSET);
|
|
151
155
|
let workingRules = FALLBACK_WORKING_RULES;
|
|
@@ -167,7 +171,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
167
171
|
const channelHint = opts.channel ? [CHANNEL_INSTRUCTIONS] : [];
|
|
168
172
|
const server = new McpServer({ name: 'orboto', version: VERSION }, {
|
|
169
173
|
capabilities: {
|
|
170
|
-
...(toolset === 'channel' ?
|
|
174
|
+
...(toolset === 'channel' ? EMPTY_SURFACE_CAPABILITIES : { resources: { subscribe: true, listChanged: true } }),
|
|
171
175
|
...(opts.channel ? { experimental: { [CHANNEL_CAPABILITY]: {} } } : {}),
|
|
172
176
|
},
|
|
173
177
|
instructions: toolset === 'channel'
|
|
@@ -381,8 +385,10 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
381
385
|
reg('orboto_admin_translation_revert', revertTranslationToolConfig, makeAdminRevertTranslationHandler(client));
|
|
382
386
|
reg('orboto_admin_agent_drift_list', listAgentDriftToolConfig, makeListAgentDriftHandler(client));
|
|
383
387
|
reg('orboto_admin_agent_drift_resolve', resolveAgentDriftToolConfig, makeResolveAgentDriftHandler(client));
|
|
384
|
-
if (toolset === 'channel')
|
|
388
|
+
if (toolset === 'channel') {
|
|
389
|
+
registerEmptySurfaces(server);
|
|
385
390
|
return server;
|
|
391
|
+
}
|
|
386
392
|
registerOrbotoResources(server, client);
|
|
387
393
|
registerOrbotoPrompts(server);
|
|
388
394
|
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.'),
|
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.8",
|
|
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",
|