@vellumai/assistant 0.11.2-dev.202608071425.368249e → 0.11.2-dev.202608071521.2d2f93e
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/package.json +1 -1
- package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +370 -0
- package/src/plugins/mcp-servers.ts +343 -0
- package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/local-actor-identity.ts +18 -5
- package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
- package/src/runtime/routes/events-routes.ts +17 -16
- package/src/runtime/routes/mcp-auth-routes.ts +141 -57
- package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
- package/src/subagent/types.ts +7 -6
package/package.json
CHANGED
|
@@ -27,6 +27,10 @@ let fakeGuardianPrincipalId: string | undefined = undefined;
|
|
|
27
27
|
// exactly when the SSE self-heal lookup completes.
|
|
28
28
|
const pendingAsyncResolutions: Array<(value: string | undefined) => void> = [];
|
|
29
29
|
|
|
30
|
+
/** Options each async resolution was called with, in the same order. */
|
|
31
|
+
const asyncResolutionOptions: Array<{ forceRefresh?: boolean } | undefined> =
|
|
32
|
+
[];
|
|
33
|
+
|
|
30
34
|
mock.module("../config/env.js", () => ({
|
|
31
35
|
isHttpAuthDisabled: () => fakeHttpAuthDisabled,
|
|
32
36
|
hasUngatedHttpAuthDisabled: () => false,
|
|
@@ -42,10 +46,14 @@ mock.module("../runtime/local-actor-identity.js", () => ({
|
|
|
42
46
|
}
|
|
43
47
|
return fakeGuardianPrincipalId;
|
|
44
48
|
},
|
|
45
|
-
resolveActorPrincipalIdForLocalGuardian: (
|
|
49
|
+
resolveActorPrincipalIdForLocalGuardian: (
|
|
50
|
+
rawHeader: string | undefined,
|
|
51
|
+
options?: { forceRefresh?: boolean },
|
|
52
|
+
) => {
|
|
46
53
|
if (rawHeader !== "dev-bypass" || !fakeHttpAuthDisabled) {
|
|
47
54
|
return Promise.resolve(rawHeader);
|
|
48
55
|
}
|
|
56
|
+
asyncResolutionOptions.push(options);
|
|
49
57
|
return new Promise<string | undefined>((resolve) => {
|
|
50
58
|
pendingAsyncResolutions.push(resolve);
|
|
51
59
|
});
|
|
@@ -57,6 +65,23 @@ async function flushMicrotasks(): Promise<void> {
|
|
|
57
65
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Poll until `predicate` holds, for assertions that depend on the heal's
|
|
70
|
+
* retry backoff timer rather than on promise settling alone.
|
|
71
|
+
*/
|
|
72
|
+
async function waitFor(
|
|
73
|
+
predicate: () => boolean,
|
|
74
|
+
timeoutMs = 3_000,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (!predicate()) {
|
|
78
|
+
if (Date.now() >= deadline) {
|
|
79
|
+
throw new Error("waitFor timed out");
|
|
80
|
+
}
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
// ── Real imports (after mocks) ────────────────────────────────────────────
|
|
61
86
|
|
|
62
87
|
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
@@ -69,6 +94,7 @@ afterAll(() => {
|
|
|
69
94
|
describe("events SSE registration — dev-bypass actor translation", () => {
|
|
70
95
|
beforeEach(() => {
|
|
71
96
|
pendingAsyncResolutions.length = 0;
|
|
97
|
+
asyncResolutionOptions.length = 0;
|
|
72
98
|
});
|
|
73
99
|
|
|
74
100
|
test("translates 'dev-bypass' to the real guardian principalId when auth is disabled", () => {
|
|
@@ -261,6 +287,91 @@ describe("events SSE registration — dev-bypass actor translation", () => {
|
|
|
261
287
|
ac2.abort();
|
|
262
288
|
});
|
|
263
289
|
|
|
290
|
+
test("retries the heal after a failed lookup instead of giving up for the connection's lifetime", async () => {
|
|
291
|
+
// The gateway IPC returns null on any transport failure, so the first
|
|
292
|
+
// lookup can miss even when a guardian is bound. A subscription left
|
|
293
|
+
// principal-less rejects every host-proxy result it submits, so the heal
|
|
294
|
+
// has to outlive one miss rather than stopping there.
|
|
295
|
+
fakeHttpAuthDisabled = true;
|
|
296
|
+
fakeGuardianPrincipalId = undefined; // cold cache: sync resolution misses
|
|
297
|
+
|
|
298
|
+
const ac = new AbortController();
|
|
299
|
+
const hub = new AssistantEventHub();
|
|
300
|
+
|
|
301
|
+
handleSubscribeAssistantEvents(
|
|
302
|
+
{
|
|
303
|
+
headers: {
|
|
304
|
+
"x-vellum-client-id": "retry-client-001",
|
|
305
|
+
"x-vellum-interface-id": "chrome-extension",
|
|
306
|
+
"x-vellum-actor-principal-id": "dev-bypass",
|
|
307
|
+
},
|
|
308
|
+
abortSignal: ac.signal,
|
|
309
|
+
},
|
|
310
|
+
{ hub },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// First attempt fails (gateway not reachable yet).
|
|
314
|
+
expect(pendingAsyncResolutions).toHaveLength(1);
|
|
315
|
+
pendingAsyncResolutions.shift()!(undefined);
|
|
316
|
+
await flushMicrotasks();
|
|
317
|
+
expect(
|
|
318
|
+
hub.getActorPrincipalIdForClient("retry-client-001"),
|
|
319
|
+
).toBeUndefined();
|
|
320
|
+
|
|
321
|
+
// The retry fires on the backoff and succeeds this time.
|
|
322
|
+
await waitFor(() => pendingAsyncResolutions.length === 1);
|
|
323
|
+
pendingAsyncResolutions.shift()!("guardian-real-id");
|
|
324
|
+
await flushMicrotasks();
|
|
325
|
+
|
|
326
|
+
expect(hub.getActorPrincipalIdForClient("retry-client-001")).toBe(
|
|
327
|
+
"guardian-real-id",
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Every attempt must bypass the guardian-delivery cache. A successful read
|
|
331
|
+
// that finds no binding is cached for minutes, which outlives the retry
|
|
332
|
+
// schedule, so a cached read would spend all attempts on the same answer
|
|
333
|
+
// and never observe a binding created in between.
|
|
334
|
+
expect(asyncResolutionOptions).toEqual([
|
|
335
|
+
{ forceRefresh: true },
|
|
336
|
+
{ forceRefresh: true },
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
ac.abort();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("needsActorPrincipalHeal reports only live, principal-less client subscriptions", () => {
|
|
343
|
+
const hub = new AssistantEventHub();
|
|
344
|
+
const bare = hub.subscribe({
|
|
345
|
+
type: "client",
|
|
346
|
+
clientId: "needs-heal-001",
|
|
347
|
+
interfaceId: "chrome-extension",
|
|
348
|
+
capabilities: [],
|
|
349
|
+
callback: () => {},
|
|
350
|
+
});
|
|
351
|
+
const withPrincipal = hub.subscribe({
|
|
352
|
+
type: "client",
|
|
353
|
+
clientId: "needs-heal-002",
|
|
354
|
+
interfaceId: "macos",
|
|
355
|
+
capabilities: [],
|
|
356
|
+
actorPrincipalId: "guardian-real-id",
|
|
357
|
+
callback: () => {},
|
|
358
|
+
});
|
|
359
|
+
const process = hub.subscribe({ type: "process", callback: () => {} });
|
|
360
|
+
|
|
361
|
+
expect(hub.needsActorPrincipalHeal(bare.connectionId)).toBe(true);
|
|
362
|
+
expect(hub.needsActorPrincipalHeal(withPrincipal.connectionId)).toBe(false);
|
|
363
|
+
expect(hub.needsActorPrincipalHeal(process.connectionId)).toBe(false);
|
|
364
|
+
expect(hub.needsActorPrincipalHeal("conn-does-not-exist")).toBe(false);
|
|
365
|
+
|
|
366
|
+
// A disposed connection stops needing a heal, which is what ends the
|
|
367
|
+
// retry loop when a client disconnects mid-backoff.
|
|
368
|
+
bare.dispose();
|
|
369
|
+
expect(hub.needsActorPrincipalHeal(bare.connectionId)).toBe(false);
|
|
370
|
+
|
|
371
|
+
withPrincipal.dispose();
|
|
372
|
+
process.dispose();
|
|
373
|
+
});
|
|
374
|
+
|
|
264
375
|
test("fillClientActorPrincipalId never overwrites a present principal", () => {
|
|
265
376
|
const hub = new AssistantEventHub();
|
|
266
377
|
const sub = hub.subscribe({
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-level coverage for plugin-declared MCP servers appearing in
|
|
3
|
+
* `internal_mcp_list` (what `assistant mcp list` renders).
|
|
4
|
+
*
|
|
5
|
+
* The unit tests in `plugins/__tests__/mcp-servers.test.ts` cover reading
|
|
6
|
+
* `mcp.json`. This file covers the merge, and two properties that a
|
|
7
|
+
* refactor could quietly break:
|
|
8
|
+
*
|
|
9
|
+
* 1. A workspace entry of the same id wins. Getting precedence backwards
|
|
10
|
+
* would let a plugin redirect a server the user configured by hand.
|
|
11
|
+
* 2. A plugin server is never health-checked. `McpClient.connect` resolves
|
|
12
|
+
* `mcp:<serverId>:headers` and `mcp:<serverId>:tokens` from the
|
|
13
|
+
* credential store, and a plugin controls both its server key and its
|
|
14
|
+
* URL, so probing one would send a workspace credential to an endpoint
|
|
15
|
+
* the plugin chose whenever an id happens to match a stored key.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
21
|
+
|
|
22
|
+
const mockConnect = jest.fn();
|
|
23
|
+
const mockDisconnect = jest.fn();
|
|
24
|
+
/** Server ids the route actually constructed an MCP client for. */
|
|
25
|
+
const connectedServerIds: string[] = [];
|
|
26
|
+
|
|
27
|
+
mock.module("../mcp/client.js", () => ({
|
|
28
|
+
McpClient: class {
|
|
29
|
+
constructor(serverId: string) {
|
|
30
|
+
connectedServerIds.push(serverId);
|
|
31
|
+
}
|
|
32
|
+
get isConnected() {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
get lastError() {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
connect = mockConnect;
|
|
39
|
+
disconnect = mockDisconnect;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module("../mcp/mcp-auth-orchestrator.js", () => ({
|
|
44
|
+
orchestrateMcpOAuthConnect: async () => ({
|
|
45
|
+
auth_url: "",
|
|
46
|
+
already_authenticated: false,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
mock.module("../mcp/mcp-auth-state.js", () => ({
|
|
51
|
+
getMcpAuthState: () => null,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
mock.module("../mcp/mcp-oauth-provider.js", () => ({
|
|
55
|
+
// Stand in for a credential store that holds tokens for every id, which
|
|
56
|
+
// is the condition under which a leak would be observable.
|
|
57
|
+
hasMcpOAuthTokens: async () => true,
|
|
58
|
+
deleteMcpOAuthCredentials: async () => ({ ok: true, failedKeys: [] }),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
mock.module("../mcp/mcp-header-store.js", () => ({
|
|
62
|
+
getMcpHeaders: async () => ({ Authorization: "Bearer workspace-secret" }),
|
|
63
|
+
setMcpHeaders: async () => {},
|
|
64
|
+
deleteMcpHeaders: async () => true,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
mock.module("../daemon/mcp-reload-service.js", () => ({
|
|
68
|
+
reloadMcpServers: async () => {},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
72
|
+
|
|
73
|
+
setConfig("mcp", {
|
|
74
|
+
servers: {
|
|
75
|
+
"from-workspace": {
|
|
76
|
+
transport: { type: "streamable-http", url: "https://config.example/mcp" },
|
|
77
|
+
enabled: true,
|
|
78
|
+
defaultRiskLevel: "high",
|
|
79
|
+
maxTools: 20,
|
|
80
|
+
},
|
|
81
|
+
// Deliberately shares an id with the `shadowed` plugin below.
|
|
82
|
+
shadowed: {
|
|
83
|
+
transport: { type: "streamable-http", url: "https://wins.example/mcp" },
|
|
84
|
+
enabled: true,
|
|
85
|
+
defaultRiskLevel: "low",
|
|
86
|
+
maxTools: 20,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { getWorkspacePluginsDir } = await import("../util/platform.js");
|
|
92
|
+
const { ROUTES } = await import("../runtime/routes/mcp-auth-routes.js");
|
|
93
|
+
|
|
94
|
+
const listHandler = ROUTES.find(
|
|
95
|
+
(r: { operationId: string }) => r.operationId === "internal_mcp_list",
|
|
96
|
+
)!.handler;
|
|
97
|
+
|
|
98
|
+
interface ListedServer {
|
|
99
|
+
id: string;
|
|
100
|
+
status: string;
|
|
101
|
+
source?: "workspace" | "plugin";
|
|
102
|
+
pluginName?: string;
|
|
103
|
+
defaultRiskLevel: string;
|
|
104
|
+
hasOAuth: boolean;
|
|
105
|
+
hasStaticAuth: boolean;
|
|
106
|
+
authType: string;
|
|
107
|
+
transport: { type: string; url?: string };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writePlugin(name: string, mcpJson: unknown): void {
|
|
111
|
+
const dir = join(getWorkspacePluginsDir(), name);
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(dir, "package.json"),
|
|
115
|
+
JSON.stringify({ name, version: "1.0.0" }),
|
|
116
|
+
);
|
|
117
|
+
writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unabyssManifest(): unknown {
|
|
121
|
+
return {
|
|
122
|
+
mcpServers: {
|
|
123
|
+
unabyss: { type: "streamable-http", url: "https://mcp.unabyss.com" },
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function listServers(): Promise<ListedServer[]> {
|
|
129
|
+
const result = (await listHandler({})) as { servers: ListedServer[] };
|
|
130
|
+
return result.servers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("internal_mcp_list, plugin-declared servers", () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
mockConnect.mockReset();
|
|
136
|
+
mockDisconnect.mockReset();
|
|
137
|
+
connectedServerIds.length = 0;
|
|
138
|
+
rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
|
|
139
|
+
mkdirSync(getWorkspacePluginsDir(), { recursive: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a plugin's mcp.json server appears alongside workspace servers", async () => {
|
|
143
|
+
writePlugin("unabyss", unabyssManifest());
|
|
144
|
+
|
|
145
|
+
const ids = (await listServers()).map((s) => s.id);
|
|
146
|
+
expect(ids).toContain("from-workspace");
|
|
147
|
+
expect(ids).toContain("unabyss");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("plugin servers are labelled with their origin, workspace servers are not", async () => {
|
|
151
|
+
writePlugin("unabyss", unabyssManifest());
|
|
152
|
+
|
|
153
|
+
const servers = await listServers();
|
|
154
|
+
const plugin = servers.find((s) => s.id === "unabyss")!;
|
|
155
|
+
const workspace = servers.find((s) => s.id === "from-workspace")!;
|
|
156
|
+
|
|
157
|
+
expect(plugin.source).toEqual("plugin");
|
|
158
|
+
expect(plugin.pluginName).toEqual("unabyss");
|
|
159
|
+
expect(workspace.source).toEqual("workspace");
|
|
160
|
+
expect(workspace.pluginName).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a workspace server of the same id wins over the plugin's", async () => {
|
|
164
|
+
writePlugin("shadowed", {
|
|
165
|
+
mcpServers: {
|
|
166
|
+
shadowed: { type: "streamable-http", url: "https://loses.example/mcp" },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const matches = (await listServers()).filter((s) => s.id === "shadowed");
|
|
171
|
+
|
|
172
|
+
expect(matches).toHaveLength(1);
|
|
173
|
+
expect(matches[0].source).toEqual("workspace");
|
|
174
|
+
expect(matches[0].transport.url).toEqual("https://wins.example/mcp");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("plugin servers are not health-checked, so no credential can reach them", async () => {
|
|
178
|
+
writePlugin("unabyss", unabyssManifest());
|
|
179
|
+
|
|
180
|
+
const servers = await listServers();
|
|
181
|
+
|
|
182
|
+
expect(servers.find((s) => s.id === "unabyss")!.status).toEqual("declared");
|
|
183
|
+
// The credential mocks above return a token and an Authorization header
|
|
184
|
+
// for every id. Constructing a client for the plugin server is what
|
|
185
|
+
// would ship them to the plugin-declared URL.
|
|
186
|
+
expect(connectedServerIds).toContain("from-workspace");
|
|
187
|
+
expect(connectedServerIds).not.toContain("unabyss");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("plugin servers report no assistant-owned auth even when the store has some", async () => {
|
|
191
|
+
writePlugin("unabyss", unabyssManifest());
|
|
192
|
+
|
|
193
|
+
const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
|
|
194
|
+
expect(plugin.hasOAuth).toBe(false);
|
|
195
|
+
expect(plugin.hasStaticAuth).toBe(false);
|
|
196
|
+
expect(plugin.authType).toEqual("none");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("workspace servers keep reporting their auth state", async () => {
|
|
200
|
+
const workspace = (await listServers()).find(
|
|
201
|
+
(s) => s.id === "from-workspace",
|
|
202
|
+
)!;
|
|
203
|
+
expect(workspace.hasOAuth).toBe(true);
|
|
204
|
+
expect(workspace.hasStaticAuth).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("plugin servers default to high risk", async () => {
|
|
208
|
+
writePlugin("unabyss", unabyssManifest());
|
|
209
|
+
|
|
210
|
+
const servers = await listServers();
|
|
211
|
+
expect(servers.find((s) => s.id === "unabyss")!.defaultRiskLevel).toEqual(
|
|
212
|
+
"high",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a directory with no valid package.json is not advertised", async () => {
|
|
217
|
+
// The runtime loader would never load it, and its stdio command must
|
|
218
|
+
// not be reachable through the listing.
|
|
219
|
+
const dir = join(getWorkspacePluginsDir(), "no-manifest");
|
|
220
|
+
mkdirSync(dir, { recursive: true });
|
|
221
|
+
writeFileSync(
|
|
222
|
+
join(dir, "mcp.json"),
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
mcpServers: { evil: { type: "stdio", command: "touch" } },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const ids = (await listServers()).map((s) => s.id);
|
|
229
|
+
expect(ids).not.toContain("no-manifest__evil");
|
|
230
|
+
expect(ids).toContain("from-workspace");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a malformed plugin manifest does not remove workspace servers", async () => {
|
|
234
|
+
const dir = join(getWorkspacePluginsDir(), "broken");
|
|
235
|
+
mkdirSync(dir, { recursive: true });
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(dir, "package.json"),
|
|
238
|
+
JSON.stringify({ name: "broken", version: "1.0.0" }),
|
|
239
|
+
);
|
|
240
|
+
writeFileSync(join(dir, "mcp.json"), "{ not json");
|
|
241
|
+
|
|
242
|
+
const ids = (await listServers()).map((s) => s.id);
|
|
243
|
+
expect(ids).toContain("from-workspace");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("no plugins installed leaves the listing unchanged", async () => {
|
|
247
|
+
const servers = await listServers();
|
|
248
|
+
expect(servers.every((s) => s.source === "workspace")).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -7,7 +7,8 @@ export const mcpHelp: CliCommandHelp = {
|
|
|
7
7
|
description: "Manage MCP (Model Context Protocol) servers",
|
|
8
8
|
helpText: `
|
|
9
9
|
MCP servers extend the assistant's capabilities with external tools. Servers
|
|
10
|
-
are configured in the assistant's config.json under the mcp.servers key
|
|
10
|
+
are configured in the assistant's config.json under the mcp.servers key, or
|
|
11
|
+
declared by an installed plugin in its root mcp.json. Each
|
|
11
12
|
server uses one of three transport types:
|
|
12
13
|
|
|
13
14
|
stdio Local process communicating over stdin/stdout
|
|
@@ -28,13 +29,21 @@ Examples:
|
|
|
28
29
|
description: "List configured MCP servers and their status",
|
|
29
30
|
options: [{ flags: "--json", description: "Output as JSON" }],
|
|
30
31
|
helpText: `
|
|
31
|
-
Shows each
|
|
32
|
+
Shows each MCP server with its current status and configuration. Servers come
|
|
33
|
+
from two places: the mcp.servers key in config.json, and the root mcp.json of
|
|
34
|
+
any installed plugin that declares one.
|
|
32
35
|
|
|
33
|
-
Name The server identifier
|
|
34
|
-
Status Health check result:
|
|
36
|
+
Name The server identifier
|
|
37
|
+
Status Health check result for workspace servers:
|
|
35
38
|
✓ Connected and responding
|
|
36
39
|
✗ Error or disabled
|
|
37
40
|
! Needs authentication (OAuth required)
|
|
41
|
+
Plugin-declared servers report "declared" instead. They are
|
|
42
|
+
listed but not connected by the assistant, and they are not
|
|
43
|
+
health-checked, so no stored credential can reach a URL a
|
|
44
|
+
plugin chose.
|
|
45
|
+
Source Shown only for plugin-declared servers, naming the plugin.
|
|
46
|
+
Servers from config.json print no Source line.
|
|
38
47
|
Transport stdio, sse, or streamable-http
|
|
39
48
|
URL/Command The server URL (sse/streamable-http) or command (stdio)
|
|
40
49
|
Risk Default risk level: low, medium, or high
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -24,6 +24,10 @@ interface McpServerEntry {
|
|
|
24
24
|
defaultRiskLevel: string;
|
|
25
25
|
allowedTools?: string[];
|
|
26
26
|
blockedTools?: string[];
|
|
27
|
+
/** Workspace `config.json` or a plugin's `mcp.json`. */
|
|
28
|
+
source?: "workspace" | "plugin";
|
|
29
|
+
/** Plugin that declared the server, when `source` is `plugin`. */
|
|
30
|
+
pluginName?: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// ---------------------------------------------------------------------------
|
|
@@ -81,6 +85,11 @@ async function pollMcpAuthStatus(
|
|
|
81
85
|
function printServerEntry(entry: McpServerEntry): void {
|
|
82
86
|
log.info(` ${entry.id}`);
|
|
83
87
|
log.info(` Status: ${entry.status}`);
|
|
88
|
+
// Only plugin-declared servers name a source. A workspace server is the
|
|
89
|
+
// default case and printing "workspace" on every one of them is noise.
|
|
90
|
+
if (entry.source === "plugin") {
|
|
91
|
+
log.info(` Source: plugin (${entry.pluginName ?? "unknown"})`);
|
|
92
|
+
}
|
|
84
93
|
log.info(` Transport: ${entry.transport?.type ?? "unknown"}`);
|
|
85
94
|
if (entry.transport?.type === "stdio") {
|
|
86
95
|
log.info(
|