@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608071425.368249e",
3
+ "version": "0.11.2-dev.202608071521.2d2f93e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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: (rawHeader: string | undefined) => {
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. Each
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 configured MCP server with its current status and configuration:
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 used in config.json
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
@@ -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(