wolfpack-mcp 1.0.101 → 1.0.103
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/index.js +4 -1
- package/dist/stdioToolPermissions.test.js +78 -0
- package/dist/toolCatalogue.js +21 -4
- package/dist/toolCatalogue.test.js +26 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -805,6 +805,8 @@ class WolfpackMCPServer {
|
|
|
805
805
|
server;
|
|
806
806
|
client;
|
|
807
807
|
capabilities = [];
|
|
808
|
+
/** The key's scopes, or null while they are unknown — see `stdioTools` (#2428). */
|
|
809
|
+
permissions = null;
|
|
808
810
|
capabilitiesLoaded = false;
|
|
809
811
|
capabilitiesFetch = null;
|
|
810
812
|
constructor() {
|
|
@@ -830,7 +832,7 @@ class WolfpackMCPServer {
|
|
|
830
832
|
}
|
|
831
833
|
return {
|
|
832
834
|
tools: [
|
|
833
|
-
...stdioTools(this.capabilities),
|
|
835
|
+
...stdioTools(this.capabilities, this.permissions),
|
|
834
836
|
...(this.capabilities.includes('agent_self') ? AGENT_SELF_TOOLS : []),
|
|
835
837
|
...(this.capabilities.includes('agent_memory') ? AGENT_MEMORY_TOOLS : []),
|
|
836
838
|
...(this.capabilities.includes('agent_observer') ? AGENT_OBSERVE_TOOLS : []),
|
|
@@ -2032,6 +2034,7 @@ class WolfpackMCPServer {
|
|
|
2032
2034
|
.getCapabilities()
|
|
2033
2035
|
.then((caps) => {
|
|
2034
2036
|
this.capabilities = caps.capabilities;
|
|
2037
|
+
this.permissions = caps.permissions ?? null;
|
|
2035
2038
|
this.capabilitiesLoaded = true;
|
|
2036
2039
|
if (this.capabilities.length > 0) {
|
|
2037
2040
|
console.error(`Capabilities: ${this.capabilities.join(', ')}`);
|
|
@@ -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
|
@@ -1547,7 +1547,9 @@ export const TOOL_CATALOGUE = [
|
|
|
1547
1547
|
name: 'list_team_members',
|
|
1548
1548
|
permission: 'mcp:team:read',
|
|
1549
1549
|
description: 'List all members in a project/team. Returns user IDs, names, usernames, roles, and whether the member is an agent. ' +
|
|
1550
|
-
'Use this to look up user IDs when assigning work items or issues.'
|
|
1550
|
+
'Use this to look up user IDs when assigning work items or issues. ' +
|
|
1551
|
+
'Agents an operator has switched off are left out, so every agent listed is one that can currently act. ' +
|
|
1552
|
+
'Note the "Disabled" role is a zero-permission membership role, not an agent being switched off.',
|
|
1551
1553
|
inputSchema: {
|
|
1552
1554
|
type: 'object',
|
|
1553
1555
|
properties: {
|
|
@@ -2135,9 +2137,24 @@ const toTool = ({ name, description, inputSchema }) => ({
|
|
|
2135
2137
|
description,
|
|
2136
2138
|
inputSchema,
|
|
2137
2139
|
});
|
|
2138
|
-
/**
|
|
2139
|
-
|
|
2140
|
-
|
|
2140
|
+
/**
|
|
2141
|
+
* Whether `permissions` carries `required`, read exactly as the backend reads it
|
|
2142
|
+
* in `checkMcpPermission` — an exact match, or a `…:*` wildcard above it.
|
|
2143
|
+
*/
|
|
2144
|
+
const holdsScope = (permissions, required) => permissions.some((p) => p === required || (p.endsWith(':*') && required.startsWith(p.slice(0, -1))));
|
|
2145
|
+
/**
|
|
2146
|
+
* The catalogue tools the stdio transport offers a key holding `capabilities`
|
|
2147
|
+
* and `permissions`.
|
|
2148
|
+
*
|
|
2149
|
+
* `permissions` absent means the key's scopes are not known — the `/capabilities`
|
|
2150
|
+
* round trip has not succeeded yet (#1488), or the backend predates #2428 — and
|
|
2151
|
+
* everything the capabilities allow is advertised, as it always was. An empty
|
|
2152
|
+
* array is the opposite: a key that holds no scopes, and is offered only the
|
|
2153
|
+
* tools that deliberately require none.
|
|
2154
|
+
*/
|
|
2155
|
+
export function stdioTools(capabilities, permissions) {
|
|
2156
|
+
return TOOL_CATALOGUE.filter((t) => (!t.capability || capabilities.includes(t.capability)) &&
|
|
2157
|
+
(!t.permission || !permissions || holdsScope(permissions, t.permission))).map(toTool);
|
|
2141
2158
|
}
|
|
2142
2159
|
/** The names a capability gates, for routing a call to that family's handler. */
|
|
2143
2160
|
export function toolNamesFor(capability) {
|
|
@@ -66,6 +66,32 @@ describe('the MCP tool catalogue', () => {
|
|
|
66
66
|
// Gating is the only difference: nothing else drops out.
|
|
67
67
|
expect(withProcedures.length - without.length).toBe(TOOL_CATALOGUE.filter((t) => t.capability === 'procedures').length);
|
|
68
68
|
});
|
|
69
|
+
it('offers a tool over stdio only to a key holding its scope', () => {
|
|
70
|
+
// #2428 — the list told an agent it could do things the 403 then refused.
|
|
71
|
+
const readOnly = stdioTools(['project'], ['mcp:work_items:read']).map((t) => t.name);
|
|
72
|
+
expect(readOnly).toContain('list_work_items');
|
|
73
|
+
expect(readOnly).not.toContain('update_work_item');
|
|
74
|
+
expect(readOnly).not.toContain('create_issue');
|
|
75
|
+
});
|
|
76
|
+
it('keeps a tool that deliberately requires no scope of its own', () => {
|
|
77
|
+
// `permission: null` is a decision, not a gap, so an empty key still gets these.
|
|
78
|
+
const unscoped = TOOL_CATALOGUE.filter((t) => t.permission === null).map((t) => t.name);
|
|
79
|
+
expect(unscoped.length).toBeGreaterThan(0);
|
|
80
|
+
expect(stdioTools([], []).map((t) => t.name)).toEqual(unscoped);
|
|
81
|
+
});
|
|
82
|
+
it('honours a wildcard the way the server does when it checks the call', () => {
|
|
83
|
+
// Filtering has to read a scope exactly as `checkMcpPermission` does, or a
|
|
84
|
+
// key on `mcp:*` loses tools it is entitled to call.
|
|
85
|
+
expect(stdioTools([], ['mcp:*']).map((t) => t.name)).toEqual(stdioTools([]).map((t) => t.name));
|
|
86
|
+
expect(stdioTools([], ['mcp:work_items:*']).map((t) => t.name)).toContain('update_work_item');
|
|
87
|
+
expect(stdioTools([], ['mcp:work_items:*']).map((t) => t.name)).not.toContain('create_issue');
|
|
88
|
+
});
|
|
89
|
+
it("advertises everything when the key's scopes are unknown", () => {
|
|
90
|
+
// A `/capabilities` round trip that has not succeeded yet (#1488) must not
|
|
91
|
+
// strip the list — an outage is not an answer about what the key may do.
|
|
92
|
+
expect(stdioTools(['project']).map((t) => t.name)).toEqual(stdioTools(['project'], null).map((t) => t.name));
|
|
93
|
+
expect(stdioTools(['project'], null).map((t) => t.name)).toContain('update_work_item');
|
|
94
|
+
});
|
|
69
95
|
it('takes the remote override only where one is set', () => {
|
|
70
96
|
// upload_image is the single tool whose two clients cannot share a contract.
|
|
71
97
|
const overridden = TOOL_CATALOGUE.filter((t) => t.remote).map((t) => t.name);
|