wolfpack-mcp 1.0.102 → 1.0.104
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/brand.js +13 -0
- package/dist/config.js +3 -2
- package/dist/index.js +14 -7
- package/dist/serverInstructions.js +5 -4
- package/dist/stdioToolPermissions.test.js +78 -0
- package/dist/toolCatalogue.js +46 -11
- package/dist/toolCatalogue.test.js +31 -2
- package/package.json +1 -1
package/dist/brand.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the product calls itself, and the domain it is served from (#2466).
|
|
3
|
+
*
|
|
4
|
+
* This package is published and installed on its own, away from the repository, so —
|
|
5
|
+
* unlike the Vite apps — it cannot read `brand.json`. It takes the values from the
|
|
6
|
+
* environment, with the fallbacks below pinned to `brand.json` by
|
|
7
|
+
* `scripts/check-product-brand.js`.
|
|
8
|
+
*
|
|
9
|
+
* `WOLFPACK_*` variables are this package's public configuration contract with the MCP
|
|
10
|
+
* clients that already set them, so they are deliberately not renamed here.
|
|
11
|
+
*/
|
|
12
|
+
export const productName = process.env.PRODUCT_NAME || 'Wolfpack';
|
|
13
|
+
export const productDomain = process.env.PRODUCT_DOMAIN || 'wolfpacks.work';
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { productDomain, productName } from './brand.js';
|
|
1
2
|
export const config = {
|
|
2
|
-
apiUrl: process.env.WOLFPACK_API_URL ||
|
|
3
|
+
apiUrl: process.env.WOLFPACK_API_URL || `https://${productDomain}/api/mcp`,
|
|
3
4
|
apiKey: process.env.WOLFPACK_API_KEY,
|
|
4
5
|
projectSlug: process.env.WOLFPACK_PROJECT_SLUG || process.env.WOLFPACK_TEAM_SLUG,
|
|
5
6
|
orgSlug: process.env.WOLFPACK_ORG_SLUG,
|
|
@@ -8,7 +9,7 @@ export const config = {
|
|
|
8
9
|
export function validateConfig() {
|
|
9
10
|
if (!config.apiKey) {
|
|
10
11
|
console.error('Error: WOLFPACK_API_KEY environment variable is required');
|
|
11
|
-
console.error(
|
|
12
|
+
console.error(`Please set WOLFPACK_API_KEY to your API key from the ${productName} application`);
|
|
12
13
|
console.error('Example: WOLFPACK_API_KEY=wfp_sk_... (user) or wfp_ak_... (agent)');
|
|
13
14
|
process.exit(1);
|
|
14
15
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,12 @@ import { createRequire } from 'module';
|
|
|
7
7
|
import { readFile, stat } from 'fs/promises';
|
|
8
8
|
import { basename, extname, resolve } from 'path';
|
|
9
9
|
import { WolfpackClient } from './client.js';
|
|
10
|
+
import { productName } from './brand.js';
|
|
10
11
|
import { allTasksChecked, getWorkItemReminders } from './workItemReminders.js';
|
|
11
12
|
import { validateConfig, config } from './config.js';
|
|
12
13
|
import { AGENT_BUILDER_TOOLS, handleAgentBuilderTool } from './agentBuilderTools.js';
|
|
13
14
|
import { handleProcedureTool } from './procedureTools.js';
|
|
14
|
-
import { stdioTools, toolNamesFor } from './toolCatalogue.js';
|
|
15
|
+
import { stdioTools, toolNamesFor, withProductName } from './toolCatalogue.js';
|
|
15
16
|
import { AGENT_SELF_TOOLS, AGENT_MEMORY_TOOLS, handleAgentSelfTool } from './agentSelfTools.js';
|
|
16
17
|
import { AGENT_OBSERVE_TOOLS, handleAgentObserveTool } from './agentObserveTools.js';
|
|
17
18
|
import { BROWSER_TOOLS, handleBrowserTool } from './browserTools.js';
|
|
@@ -65,7 +66,7 @@ const ListWorkItemsSchema = z.object({
|
|
|
65
66
|
status: z.coerce
|
|
66
67
|
.string()
|
|
67
68
|
.optional()
|
|
68
|
-
.describe('Filter by status. Board columns: "new", "doing", "
|
|
69
|
+
.describe('Filter by status. Board columns: "new", "doing", "paused", "blocked", "review", "ready", "completed". Use "pending" or "backlog" for backlog items. Use "all" to include completed/closed. Default excludes completed/closed.'),
|
|
69
70
|
assigned_to_id: z.coerce
|
|
70
71
|
.string()
|
|
71
72
|
.optional()
|
|
@@ -121,6 +122,7 @@ const VALID_STATUSES = [
|
|
|
121
122
|
'pending',
|
|
122
123
|
'new',
|
|
123
124
|
'doing',
|
|
125
|
+
'paused',
|
|
124
126
|
'blocked',
|
|
125
127
|
'review',
|
|
126
128
|
'ready',
|
|
@@ -375,7 +377,7 @@ const CreateWorkItemSchema = z.object({
|
|
|
375
377
|
status: z
|
|
376
378
|
.enum(VALID_STATUSES)
|
|
377
379
|
.optional()
|
|
378
|
-
.describe('Initial status: "pending" (backlog), "new" (to do), "doing", "
|
|
380
|
+
.describe('Initial status: "pending" (backlog), "new" (to do), "doing", "paused", "blocked", "review", "ready", "completed". Defaults to "new".'),
|
|
379
381
|
priority: z.number().optional().describe('Priority level (0-4, higher is more important)'),
|
|
380
382
|
size: z
|
|
381
383
|
.enum(['S', 'M', 'L'])
|
|
@@ -805,6 +807,8 @@ class WolfpackMCPServer {
|
|
|
805
807
|
server;
|
|
806
808
|
client;
|
|
807
809
|
capabilities = [];
|
|
810
|
+
/** The key's scopes, or null while they are unknown — see `stdioTools` (#2428). */
|
|
811
|
+
permissions = null;
|
|
808
812
|
capabilitiesLoaded = false;
|
|
809
813
|
capabilitiesFetch = null;
|
|
810
814
|
constructor() {
|
|
@@ -829,14 +833,16 @@ class WolfpackMCPServer {
|
|
|
829
833
|
await this.fetchCapabilities();
|
|
830
834
|
}
|
|
831
835
|
return {
|
|
832
|
-
|
|
833
|
-
|
|
836
|
+
// The catalogue carries a token where the product's name belongs (#2466); it is
|
|
837
|
+
// filled in here, as the tools are advertised.
|
|
838
|
+
tools: withProductName([
|
|
839
|
+
...stdioTools(this.capabilities, this.permissions),
|
|
834
840
|
...(this.capabilities.includes('agent_self') ? AGENT_SELF_TOOLS : []),
|
|
835
841
|
...(this.capabilities.includes('agent_memory') ? AGENT_MEMORY_TOOLS : []),
|
|
836
842
|
...(this.capabilities.includes('agent_observer') ? AGENT_OBSERVE_TOOLS : []),
|
|
837
843
|
...(this.capabilities.includes('agent_builder') ? AGENT_BUILDER_TOOLS : []),
|
|
838
844
|
...(this.capabilities.includes('browser_control') ? BROWSER_TOOLS : []),
|
|
839
|
-
],
|
|
845
|
+
], productName),
|
|
840
846
|
};
|
|
841
847
|
});
|
|
842
848
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -2032,6 +2038,7 @@ class WolfpackMCPServer {
|
|
|
2032
2038
|
.getCapabilities()
|
|
2033
2039
|
.then((caps) => {
|
|
2034
2040
|
this.capabilities = caps.capabilities;
|
|
2041
|
+
this.permissions = caps.permissions ?? null;
|
|
2035
2042
|
this.capabilitiesLoaded = true;
|
|
2036
2043
|
if (this.capabilities.length > 0) {
|
|
2037
2044
|
console.error(`Capabilities: ${this.capabilities.join(', ')}`);
|
|
@@ -2069,7 +2076,7 @@ class WolfpackMCPServer {
|
|
|
2069
2076
|
// Connect stdio transport FIRST so MCP clients don't deadlock waiting for initialize
|
|
2070
2077
|
const transport = new StdioServerTransport();
|
|
2071
2078
|
await this.server.connect(transport);
|
|
2072
|
-
console.error(
|
|
2079
|
+
console.error(`${productName} MCP Server v${CURRENT_VERSION} started`);
|
|
2073
2080
|
if (!this.capabilitiesLoaded) {
|
|
2074
2081
|
console.error('Starting with base tools only; retrying capabilities fetch in background');
|
|
2075
2082
|
void this.recoverCapabilities();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { productName } from './brand.js';
|
|
1
2
|
/**
|
|
2
3
|
* Server instructions, returned in the MCP `initialize` result.
|
|
3
4
|
*
|
|
@@ -12,9 +13,9 @@
|
|
|
12
13
|
* server — the two servers have no shared package, and already keep their tool
|
|
13
14
|
* descriptions in step by hand.
|
|
14
15
|
*/
|
|
15
|
-
export const SERVER_INSTRUCTIONS =
|
|
16
|
+
export const SERVER_INSTRUCTIONS = `${productName} is this project's tracker. Its references override the usual GitHub reading of "#N".
|
|
16
17
|
|
|
17
|
-
- A bare "#123" — "work on #123", "look at #42" — is ALWAYS a
|
|
18
|
+
- A bare "#123" — "work on #123", "look at #42" — is ALWAYS a ${productName} work item. Call get_work_item. Do not run \`gh issue view\`, and do not search GitHub for it.
|
|
18
19
|
- Read a number as a GitHub issue or pull request only when the user says GitHub, PR, or gh — "the GitHub issue #123", "PR #123".
|
|
19
|
-
- Every other
|
|
20
|
-
- On its own, "issue" means a
|
|
20
|
+
- Every other ${productName} type needs its prefix and is never a bare number: #i123 issues, #r123 roadmap/initiatives, #j123 journal entries, #c123 cases, #p123 procedures. Wiki pages are addressed by path, e.g. /docs/setup.
|
|
21
|
+
- On its own, "issue" means a ${productName} issue (#i123). A GitHub issue is only ever called a GitHub issue.`;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
import { createServer } from 'http';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
/**
|
|
7
|
+
* #2428 — the tool list a key is actually served, over a real stdio transport.
|
|
8
|
+
*
|
|
9
|
+
* `stdioTools` filtering in isolation proves nothing about what an agent sees:
|
|
10
|
+
* the defect was that `index.ts` never asked the backend for the key's scopes
|
|
11
|
+
* and never passed them on. So this drives the real server the way a client
|
|
12
|
+
* does, against a stub standing in for `/api/mcp/capabilities`, and reads the
|
|
13
|
+
* tool list off `listTools()`.
|
|
14
|
+
*/
|
|
15
|
+
const entryPoint = fileURLToPath(new URL('./index.ts', import.meta.url));
|
|
16
|
+
let backend;
|
|
17
|
+
/** A backend answering `/capabilities` with `body`, or failing when it is null. */
|
|
18
|
+
const stubBackend = async (body) => {
|
|
19
|
+
backend = createServer((req, res) => {
|
|
20
|
+
if (body && req.url?.endsWith('/capabilities')) {
|
|
21
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (req.url?.endsWith('/teams')) {
|
|
26
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify({ teams: [] }));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
res.writeHead(503).end();
|
|
31
|
+
});
|
|
32
|
+
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
|
|
33
|
+
return `http://127.0.0.1:${backend.address().port}/api/mcp`;
|
|
34
|
+
};
|
|
35
|
+
const toolNamesFrom = async (apiUrl) => {
|
|
36
|
+
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
37
|
+
const transport = new StdioClientTransport({
|
|
38
|
+
// tsx directly, not via npx: the transport kills the process it spawned,
|
|
39
|
+
// and an npx wrapper would leave the server orphaned behind it.
|
|
40
|
+
command: process.execPath,
|
|
41
|
+
args: ['--import', 'tsx', entryPoint],
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
WOLFPACK_API_KEY: 'wfp_sk_test',
|
|
45
|
+
WOLFPACK_API_URL: apiUrl,
|
|
46
|
+
WOLFPACK_PROJECT_SLUG: '',
|
|
47
|
+
WOLFPACK_TEAM_SLUG: '',
|
|
48
|
+
WOLFPACK_ORG_SLUG: '',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
await client.connect(transport);
|
|
52
|
+
try {
|
|
53
|
+
return (await client.listTools()).tools.map((t) => t.name);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await client.close();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await new Promise((resolve) => backend?.close(() => resolve()) ?? resolve());
|
|
61
|
+
backend = undefined;
|
|
62
|
+
});
|
|
63
|
+
describe('the tool list served over stdio', () => {
|
|
64
|
+
it('offers a read-only key the tools it may call and not the ones it may not', async () => {
|
|
65
|
+
const names = await toolNamesFrom(await stubBackend({ capabilities: ['project'], permissions: ['mcp:work_items:read'] }));
|
|
66
|
+
expect(names).toContain('list_work_items');
|
|
67
|
+
expect(names).not.toContain('update_work_item');
|
|
68
|
+
expect(names).not.toContain('create_work_item_comment');
|
|
69
|
+
}, 60_000);
|
|
70
|
+
it('is not stripped when the capabilities round trip fails', async () => {
|
|
71
|
+
// #1488 the other way up: a gateway outage means the key's scopes are
|
|
72
|
+
// unknown, not that the key holds none. Stripping the list on a 503 would
|
|
73
|
+
// be a worse regression than the one this item fixes.
|
|
74
|
+
const names = await toolNamesFrom(await stubBackend(null));
|
|
75
|
+
expect(names).toContain('list_work_items');
|
|
76
|
+
expect(names).toContain('update_work_item');
|
|
77
|
+
}, 60_000);
|
|
78
|
+
});
|
package/dist/toolCatalogue.js
CHANGED
|
@@ -21,6 +21,22 @@
|
|
|
21
21
|
* `agentBuilderTools.ts`, `agentSelfTools.ts`, `browserTools.ts`), so they have
|
|
22
22
|
* no second declaration to drift from.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The token a description carries where the product's name belongs (#2466).
|
|
26
|
+
*
|
|
27
|
+
* The catalogue is serialised into the backend's generated copy, so a description cannot
|
|
28
|
+
* interpolate the name — it would be baked in at generation time, and the copy would go
|
|
29
|
+
* stale on a rename with nothing to notice. Each transport fills the token in as it
|
|
30
|
+
* advertises its tools instead.
|
|
31
|
+
*/
|
|
32
|
+
export const PRODUCT_NAME_TOKEN = '__PRODUCT_NAME__';
|
|
33
|
+
/** The same tools, as a person reading their client's tool list should see them. */
|
|
34
|
+
export function withProductName(tools, productName) {
|
|
35
|
+
return tools.map((tool) => ({
|
|
36
|
+
...tool,
|
|
37
|
+
description: tool.description?.split(PRODUCT_NAME_TOKEN).join(productName),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
24
40
|
// Cross-reference syntax for linking between content items in markdown fields
|
|
25
41
|
const CONTENT_LINKING_HELP = 'CROSS-REFERENCES: In any markdown content, you can link to other items using these patterns: ' +
|
|
26
42
|
'#123, #w123, or #work-123 for work items (DEFAULT — bare #N always means a work item), ' +
|
|
@@ -89,9 +105,9 @@ export const TOOL_CATALOGUE = [
|
|
|
89
105
|
// Checks each entity type itself and drops the types it may not read, so a
|
|
90
106
|
// key holding only `mcp:wiki:read` still searches pages.
|
|
91
107
|
permission: null,
|
|
92
|
-
description: 'Ranked full-text search across project content: wiki pages, work items, cases, journal entries, issues and the
|
|
108
|
+
description: 'Ranked full-text search across project content: wiki pages, work items, cases, journal entries, issues and the __PRODUCT_NAME__ user manual. ' +
|
|
93
109
|
'Title matches outrank body matches; results include a snippet, entity type, and refId/slug for follow-up calls ' +
|
|
94
|
-
'(get_work_item, get_issue, get_wiki_page, ...). Results with entityType "manual" are user manual sections — platform documentation on how
|
|
110
|
+
'(get_work_item, get_issue, get_wiki_page, ...). Results with entityType "manual" are user manual sections — platform documentation on how __PRODUCT_NAME__ features work. ' +
|
|
95
111
|
'Use this to find existing content before creating new items, or when the user asks to "find" or "look up" something without saying where it lives.',
|
|
96
112
|
inputSchema: {
|
|
97
113
|
type: 'object',
|
|
@@ -132,7 +148,7 @@ export const TOOL_CATALOGUE = [
|
|
|
132
148
|
'with a non-empty blockedBy is NOT claimable however approved it is — take the work it names ' +
|
|
133
149
|
'first; the response says so too. ' +
|
|
134
150
|
'TERMINOLOGY: "board" and "kanban" are synonymous - both refer to the Kanban board of work items. ' +
|
|
135
|
-
'The board has columns: "new" (to do), "doing" (in progress), "review" (pending review), "ready" (code done, awaiting deployment), "
|
|
151
|
+
'The board has columns: "new" (to do), "doing" (in progress), "paused" (set aside by a human), "blocked", "review" (pending review), "ready" (code done, awaiting deployment), "completed" (deployed). ' +
|
|
136
152
|
'The "backlog" or "pending" status represents items not yet on the board. ' +
|
|
137
153
|
'By default, completed/closed items are excluded - use status="all" to include them. ' +
|
|
138
154
|
'IMPORTANT: Work items are NOT the same as issues. Work items live on the Kanban board/backlog. ' +
|
|
@@ -147,7 +163,7 @@ export const TOOL_CATALOGUE = [
|
|
|
147
163
|
},
|
|
148
164
|
status: {
|
|
149
165
|
type: 'string',
|
|
150
|
-
description: 'Filter by status. Board columns: "new", "doing", "
|
|
166
|
+
description: 'Filter by status. Board columns: "new", "doing", "paused", "blocked", "review", "ready", "completed". ' +
|
|
151
167
|
'Use "pending" or "backlog" for backlog items not on board. ' +
|
|
152
168
|
'Use "all" to include completed/closed items. Default excludes completed/closed.',
|
|
153
169
|
},
|
|
@@ -208,8 +224,9 @@ export const TOOL_CATALOGUE = [
|
|
|
208
224
|
'WORKFLOW: When asked to work on an item, check its status and follow the required state transitions ' +
|
|
209
225
|
'(pending→pull first, new→doing, review→doing when you are picking the work back up, ' +
|
|
210
226
|
'ready/completed/closed→new→doing, then review when done). ' +
|
|
211
|
-
'AGENTS: a "blocked" item is not yours to restart — a human clears the blocker
|
|
212
|
-
'
|
|
227
|
+
'AGENTS: a "blocked" or "paused" item is not yours to restart — a human clears the blocker, and a ' +
|
|
228
|
+
'human decides when paused work resumes. Leave it where it is, comment if you can help, and take ' +
|
|
229
|
+
'the next claimable item instead. ' +
|
|
213
230
|
'PLANNING: Check if the description contains a plan (markdown checklist). If not, APPEND one using update_work_progress - preserve all original description text and add your plan below a "---" separator. ' +
|
|
214
231
|
'FORMS: Procedure-created work items may include formDefinition (field definitions with name, label, type, required, options) and formValues (current values). Use submit_work_item_form to fill in form values. ' +
|
|
215
232
|
'REVIEW CHECKS: items under review may carry review checks (code-review, security-review, ...); see list_work_item_checks and claim/complete_work_item_check. ' +
|
|
@@ -264,7 +281,8 @@ export const TOOL_CATALOGUE = [
|
|
|
264
281
|
'STATUS WORKFLOW: "pending" (backlog) → "new" (to do) → "doing" (in progress) → "review" (work done) → "ready" (awaiting deployment) → "completed" (deployed). ' +
|
|
265
282
|
'Use "blocked" when work cannot proceed. AGENTS: you cannot start un-started work here — ' +
|
|
266
283
|
'a status change on a "pending" or "new" item that is not assigned to you is refused. ' +
|
|
267
|
-
'Nor can you move an item OUT of "blocked": clearing a blocker
|
|
284
|
+
'Nor can you move an item OUT of "blocked" or "paused": clearing a blocker, and resuming work a ' +
|
|
285
|
+
'human set aside, are human decisions. ' +
|
|
268
286
|
'Take it with pull_work_item, which assigns it to you and puts it in "doing"; that is ' +
|
|
269
287
|
'what makes the board show who is working on what. ' +
|
|
270
288
|
'When moving to "review", add a completion comment via create_work_item_comment. When moving to "blocked", add a comment explaining the blocker. ' +
|
|
@@ -291,6 +309,7 @@ export const TOOL_CATALOGUE = [
|
|
|
291
309
|
'pending',
|
|
292
310
|
'new',
|
|
293
311
|
'doing',
|
|
312
|
+
'paused',
|
|
294
313
|
'blocked',
|
|
295
314
|
'review',
|
|
296
315
|
'ready',
|
|
@@ -803,6 +822,7 @@ export const TOOL_CATALOGUE = [
|
|
|
803
822
|
'pending',
|
|
804
823
|
'new',
|
|
805
824
|
'doing',
|
|
825
|
+
'paused',
|
|
806
826
|
'blocked',
|
|
807
827
|
'review',
|
|
808
828
|
'ready',
|
|
@@ -810,7 +830,7 @@ export const TOOL_CATALOGUE = [
|
|
|
810
830
|
'closed',
|
|
811
831
|
'archived',
|
|
812
832
|
],
|
|
813
|
-
description: 'Initial status: "pending" (backlog), "new" (to do), "doing", "
|
|
833
|
+
description: 'Initial status: "pending" (backlog), "new" (to do), "doing", "paused", "blocked", "review", "ready", "completed", "closed", "archived". Defaults to "new".',
|
|
814
834
|
},
|
|
815
835
|
priority: {
|
|
816
836
|
type: 'number',
|
|
@@ -2137,9 +2157,24 @@ const toTool = ({ name, description, inputSchema }) => ({
|
|
|
2137
2157
|
description,
|
|
2138
2158
|
inputSchema,
|
|
2139
2159
|
});
|
|
2140
|
-
/**
|
|
2141
|
-
|
|
2142
|
-
|
|
2160
|
+
/**
|
|
2161
|
+
* Whether `permissions` carries `required`, read exactly as the backend reads it
|
|
2162
|
+
* in `checkMcpPermission` — an exact match, or a `…:*` wildcard above it.
|
|
2163
|
+
*/
|
|
2164
|
+
const holdsScope = (permissions, required) => permissions.some((p) => p === required || (p.endsWith(':*') && required.startsWith(p.slice(0, -1))));
|
|
2165
|
+
/**
|
|
2166
|
+
* The catalogue tools the stdio transport offers a key holding `capabilities`
|
|
2167
|
+
* and `permissions`.
|
|
2168
|
+
*
|
|
2169
|
+
* `permissions` absent means the key's scopes are not known — the `/capabilities`
|
|
2170
|
+
* round trip has not succeeded yet (#1488), or the backend predates #2428 — and
|
|
2171
|
+
* everything the capabilities allow is advertised, as it always was. An empty
|
|
2172
|
+
* array is the opposite: a key that holds no scopes, and is offered only the
|
|
2173
|
+
* tools that deliberately require none.
|
|
2174
|
+
*/
|
|
2175
|
+
export function stdioTools(capabilities, permissions) {
|
|
2176
|
+
return TOOL_CATALOGUE.filter((t) => (!t.capability || capabilities.includes(t.capability)) &&
|
|
2177
|
+
(!t.permission || !permissions || holdsScope(permissions, t.permission))).map(toTool);
|
|
2143
2178
|
}
|
|
2144
2179
|
/** The names a capability gates, for routing a call to that family's handler. */
|
|
2145
2180
|
export function toolNamesFor(capability) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import
|
|
4
|
+
import * as catalogue from './toolCatalogue.js';
|
|
5
|
+
import { TOOL_CATALOGUE, remoteTools, stdioTools } from './toolCatalogue.js';
|
|
5
6
|
import { BROWSER_TOOLS } from './browserTools.js';
|
|
6
7
|
// @ts-expect-error — a plain .mjs build script, deliberately not part of this program
|
|
7
8
|
import { render, GENERATED_PATH } from '../../../scripts/generate-mcp-catalogue.mjs';
|
|
@@ -16,7 +17,9 @@ const repoFile = (path) => readFileSync(join(__dirname, '../../..', path), 'utf8
|
|
|
16
17
|
describe('the MCP tool catalogue', () => {
|
|
17
18
|
it("is what the backend's generated copy holds", () => {
|
|
18
19
|
// The one check that makes the other declaration a copy rather than a rival.
|
|
19
|
-
|
|
20
|
+
// The whole module, exactly as the generator imports it: passing named exports
|
|
21
|
+
// one by one is how the copy went stale when the generator grew a new one (#2466).
|
|
22
|
+
expect(readFileSync(GENERATED_PATH, 'utf8')).toBe(render(catalogue));
|
|
20
23
|
});
|
|
21
24
|
it('gives every tool a name no other tool has', () => {
|
|
22
25
|
const names = TOOL_CATALOGUE.map((t) => t.name);
|
|
@@ -66,6 +69,32 @@ describe('the MCP tool catalogue', () => {
|
|
|
66
69
|
// Gating is the only difference: nothing else drops out.
|
|
67
70
|
expect(withProcedures.length - without.length).toBe(TOOL_CATALOGUE.filter((t) => t.capability === 'procedures').length);
|
|
68
71
|
});
|
|
72
|
+
it('offers a tool over stdio only to a key holding its scope', () => {
|
|
73
|
+
// #2428 — the list told an agent it could do things the 403 then refused.
|
|
74
|
+
const readOnly = stdioTools(['project'], ['mcp:work_items:read']).map((t) => t.name);
|
|
75
|
+
expect(readOnly).toContain('list_work_items');
|
|
76
|
+
expect(readOnly).not.toContain('update_work_item');
|
|
77
|
+
expect(readOnly).not.toContain('create_issue');
|
|
78
|
+
});
|
|
79
|
+
it('keeps a tool that deliberately requires no scope of its own', () => {
|
|
80
|
+
// `permission: null` is a decision, not a gap, so an empty key still gets these.
|
|
81
|
+
const unscoped = TOOL_CATALOGUE.filter((t) => t.permission === null).map((t) => t.name);
|
|
82
|
+
expect(unscoped.length).toBeGreaterThan(0);
|
|
83
|
+
expect(stdioTools([], []).map((t) => t.name)).toEqual(unscoped);
|
|
84
|
+
});
|
|
85
|
+
it('honours a wildcard the way the server does when it checks the call', () => {
|
|
86
|
+
// Filtering has to read a scope exactly as `checkMcpPermission` does, or a
|
|
87
|
+
// key on `mcp:*` loses tools it is entitled to call.
|
|
88
|
+
expect(stdioTools([], ['mcp:*']).map((t) => t.name)).toEqual(stdioTools([]).map((t) => t.name));
|
|
89
|
+
expect(stdioTools([], ['mcp:work_items:*']).map((t) => t.name)).toContain('update_work_item');
|
|
90
|
+
expect(stdioTools([], ['mcp:work_items:*']).map((t) => t.name)).not.toContain('create_issue');
|
|
91
|
+
});
|
|
92
|
+
it("advertises everything when the key's scopes are unknown", () => {
|
|
93
|
+
// A `/capabilities` round trip that has not succeeded yet (#1488) must not
|
|
94
|
+
// strip the list — an outage is not an answer about what the key may do.
|
|
95
|
+
expect(stdioTools(['project']).map((t) => t.name)).toEqual(stdioTools(['project'], null).map((t) => t.name));
|
|
96
|
+
expect(stdioTools(['project'], null).map((t) => t.name)).toContain('update_work_item');
|
|
97
|
+
});
|
|
69
98
|
it('takes the remote override only where one is set', () => {
|
|
70
99
|
// upload_image is the single tool whose two clients cannot share a contract.
|
|
71
100
|
const overridden = TOOL_CATALOGUE.filter((t) => t.remote).map((t) => t.name);
|