@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4
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/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +116 -18
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/acp/session-snapshot.ts +260 -0
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/oauth/seed-providers.ts +11 -0
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-routes.ts +17 -161
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -11,16 +11,27 @@ import { existsSync, rmSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
13
13
|
|
|
14
|
+
const start = jest.fn(async () => []);
|
|
15
|
+
const stop = jest.fn(async () => {});
|
|
16
|
+
const publishMcpChanged = jest.fn();
|
|
17
|
+
const realResourceSync =
|
|
18
|
+
await import("../../runtime/sync/resource-sync-events.js");
|
|
19
|
+
|
|
14
20
|
mock.module("../manager.js", () => ({
|
|
15
21
|
getMcpServerManager: () => ({
|
|
16
|
-
start
|
|
17
|
-
stop
|
|
22
|
+
start,
|
|
23
|
+
stop,
|
|
18
24
|
callTool: jest.fn(),
|
|
19
25
|
getClient: () => undefined,
|
|
20
26
|
}),
|
|
21
27
|
stopMcpServerManager: jest.fn(async () => {}),
|
|
22
28
|
}));
|
|
23
29
|
|
|
30
|
+
mock.module("../../runtime/sync/resource-sync-events.js", () => ({
|
|
31
|
+
...realResourceSync,
|
|
32
|
+
publishMcpChanged,
|
|
33
|
+
}));
|
|
34
|
+
|
|
24
35
|
mock.module("../mcp-header-store.js", () => ({
|
|
25
36
|
migrateLegacyMcpHeaders: async () => {},
|
|
26
37
|
}));
|
|
@@ -36,6 +47,11 @@ const { reloadMcpServers } = await import("../../daemon/mcp-reload-service.js");
|
|
|
36
47
|
const signalPath = () => join(getSignalsDir(), MCP_RELOAD_SIGNAL_FILE);
|
|
37
48
|
|
|
38
49
|
beforeEach(() => {
|
|
50
|
+
start.mockReset();
|
|
51
|
+
start.mockResolvedValue([]);
|
|
52
|
+
stop.mockReset();
|
|
53
|
+
stop.mockResolvedValue(undefined);
|
|
54
|
+
publishMcpChanged.mockReset();
|
|
39
55
|
rmSync(signalPath(), { force: true });
|
|
40
56
|
});
|
|
41
57
|
|
|
@@ -51,4 +67,25 @@ describe("reloadMcpServers", () => {
|
|
|
51
67
|
"servers the config no longer lists.",
|
|
52
68
|
).toBe(true);
|
|
53
69
|
});
|
|
70
|
+
|
|
71
|
+
test("publishes status only after a slow reload settles", async () => {
|
|
72
|
+
let finishStop: (() => void) | undefined;
|
|
73
|
+
stop.mockImplementationOnce(
|
|
74
|
+
() =>
|
|
75
|
+
new Promise<void>((resolve) => {
|
|
76
|
+
finishStop = resolve;
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const reload = reloadMcpServers();
|
|
81
|
+
while (!finishStop) {
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
}
|
|
84
|
+
expect(publishMcpChanged).not.toHaveBeenCalled();
|
|
85
|
+
|
|
86
|
+
finishStop!();
|
|
87
|
+
await reload;
|
|
88
|
+
|
|
89
|
+
expect(publishMcpChanged).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
54
91
|
});
|
package/src/mcp/client.ts
CHANGED
|
@@ -5,11 +5,18 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
5
5
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
6
|
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
|
|
8
|
+
import type {
|
|
9
|
+
McpServerSource,
|
|
10
|
+
McpTransport,
|
|
11
|
+
ResolvedMcpServerConfig,
|
|
12
|
+
} from "../config/schemas/mcp.js";
|
|
10
13
|
import { getLogger } from "../util/logger.js";
|
|
14
|
+
import {
|
|
15
|
+
resolveMcpOAuthCredentialTarget,
|
|
16
|
+
workspaceMcpOAuthCredentialTarget,
|
|
17
|
+
} from "./credential-target.js";
|
|
11
18
|
import { getMcpHeaders } from "./mcp-header-store.js";
|
|
12
|
-
import { McpOAuthProvider } from "./mcp-oauth-provider.js";
|
|
19
|
+
import { hasMcpOAuthTokens, McpOAuthProvider } from "./mcp-oauth-provider.js";
|
|
13
20
|
|
|
14
21
|
const log = getLogger("mcp-client");
|
|
15
22
|
|
|
@@ -55,11 +62,12 @@ export interface McpCallResult {
|
|
|
55
62
|
export class McpClient {
|
|
56
63
|
readonly serverId: string;
|
|
57
64
|
/**
|
|
58
|
-
* Where this server was declared.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
65
|
+
* Where this server was declared. Workspace servers use their public id for
|
|
66
|
+
* OAuth and static-header identity. Plugin servers use their declaring owner,
|
|
67
|
+
* original key, and endpoint for OAuth and never read workspace headers.
|
|
61
68
|
*/
|
|
62
69
|
readonly source: McpServerSource;
|
|
70
|
+
private readonly serverConfig: ResolvedMcpServerConfig | null;
|
|
63
71
|
private client: Client;
|
|
64
72
|
private transport:
|
|
65
73
|
| StdioClientTransport
|
|
@@ -79,9 +87,18 @@ export class McpClient {
|
|
|
79
87
|
return this.connected;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
constructor(
|
|
90
|
+
constructor(
|
|
91
|
+
serverId: string,
|
|
92
|
+
sourceOrConfig: McpServerSource | ResolvedMcpServerConfig = "workspace",
|
|
93
|
+
private readonly onUnexpectedClose?: () => void,
|
|
94
|
+
) {
|
|
83
95
|
this.serverId = serverId;
|
|
84
|
-
this.source =
|
|
96
|
+
this.source =
|
|
97
|
+
typeof sourceOrConfig === "string"
|
|
98
|
+
? sourceOrConfig
|
|
99
|
+
: sourceOrConfig.source;
|
|
100
|
+
this.serverConfig =
|
|
101
|
+
typeof sourceOrConfig === "string" ? null : sourceOrConfig;
|
|
85
102
|
this.client = new Client({
|
|
86
103
|
name: "vellum-assistant",
|
|
87
104
|
version: "1.0.0",
|
|
@@ -96,6 +113,14 @@ export class McpClient {
|
|
|
96
113
|
"MCP SDK transport error (non-fatal)",
|
|
97
114
|
);
|
|
98
115
|
};
|
|
116
|
+
this.client.onclose = () => {
|
|
117
|
+
const wasConnected = this.connected;
|
|
118
|
+
this.connected = false;
|
|
119
|
+
this.transport = null;
|
|
120
|
+
if (wasConnected) {
|
|
121
|
+
this.onUnexpectedClose?.();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
async connect(transportConfig: McpTransport): Promise<void> {
|
|
@@ -106,21 +131,20 @@ export class McpClient {
|
|
|
106
131
|
const isHttpTransport =
|
|
107
132
|
transportConfig.type === "sse" ||
|
|
108
133
|
transportConfig.type === "streamable-http";
|
|
109
|
-
const
|
|
134
|
+
const credentialTarget = this.resolveCredentialTarget(transportConfig);
|
|
135
|
+
this.oauthProvider = null;
|
|
110
136
|
|
|
111
137
|
// For HTTP transports, only attach an OAuth provider if cached tokens
|
|
112
138
|
// exist. This avoids triggering client registration during daemon
|
|
113
139
|
// startup. If no tokens, try without auth: if the server requires it,
|
|
114
140
|
// skip silently.
|
|
115
|
-
if (isHttpTransport &&
|
|
116
|
-
|
|
117
|
-
`mcp:${this.serverId}:tokens`,
|
|
118
|
-
);
|
|
119
|
-
if (cachedTokens) {
|
|
141
|
+
if (isHttpTransport && credentialTarget) {
|
|
142
|
+
if (await hasMcpOAuthTokens(credentialTarget)) {
|
|
120
143
|
this.oauthProvider = new McpOAuthProvider(
|
|
121
144
|
this.serverId,
|
|
122
145
|
transportConfig.url,
|
|
123
146
|
/* interactive */ false,
|
|
147
|
+
{ credentialTarget },
|
|
124
148
|
);
|
|
125
149
|
}
|
|
126
150
|
}
|
|
@@ -128,7 +152,7 @@ export class McpClient {
|
|
|
128
152
|
// Resolve static auth headers from credential store, falling back to
|
|
129
153
|
// any legacy headers in the transport config for backward compatibility.
|
|
130
154
|
let effectiveConfig = transportConfig;
|
|
131
|
-
if (isHttpTransport &&
|
|
155
|
+
if (isHttpTransport && this.source === "workspace") {
|
|
132
156
|
const storedHeaders = await getMcpHeaders(this.serverId);
|
|
133
157
|
if (storedHeaders) {
|
|
134
158
|
effectiveConfig = {
|
|
@@ -184,10 +208,34 @@ export class McpClient {
|
|
|
184
208
|
return;
|
|
185
209
|
}
|
|
186
210
|
|
|
211
|
+
if (this.transport === null) {
|
|
212
|
+
this._lastError = new Error(
|
|
213
|
+
`MCP server "${this.serverId}" closed during initialization`,
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
187
218
|
this.connected = true;
|
|
188
219
|
log.info({ serverId: this.serverId }, "MCP client connected");
|
|
189
220
|
}
|
|
190
221
|
|
|
222
|
+
private resolveCredentialTarget(
|
|
223
|
+
transport: McpTransport,
|
|
224
|
+
): ReturnType<typeof resolveMcpOAuthCredentialTarget> {
|
|
225
|
+
if (this.source === "workspace") {
|
|
226
|
+
return transport.type === "stdio"
|
|
227
|
+
? null
|
|
228
|
+
: workspaceMcpOAuthCredentialTarget(this.serverId);
|
|
229
|
+
}
|
|
230
|
+
if (!this.serverConfig) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return resolveMcpOAuthCredentialTarget(this.serverId, {
|
|
234
|
+
...this.serverConfig,
|
|
235
|
+
transport,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
191
239
|
async listTools(): Promise<McpToolInfo[]> {
|
|
192
240
|
if (!this.connected) {
|
|
193
241
|
throw new Error(`MCP client "${this.serverId}" is not connected`);
|
|
@@ -278,12 +326,12 @@ export class McpClient {
|
|
|
278
326
|
return;
|
|
279
327
|
}
|
|
280
328
|
|
|
329
|
+
this.connected = false;
|
|
281
330
|
try {
|
|
282
331
|
await this.client.close();
|
|
283
332
|
} catch (err) {
|
|
284
333
|
log.warn({ err, serverId: this.serverId }, "Error closing MCP client");
|
|
285
334
|
}
|
|
286
|
-
this.connected = false;
|
|
287
335
|
this.transport = null;
|
|
288
336
|
log.info({ serverId: this.serverId }, "MCP client disconnected");
|
|
289
337
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
McpTransport,
|
|
5
|
+
ResolvedMcpServerConfig,
|
|
6
|
+
} from "../config/schemas/mcp.js";
|
|
7
|
+
|
|
8
|
+
export const MCP_OAUTH_CREDENTIAL_LEAVES = [
|
|
9
|
+
"tokens",
|
|
10
|
+
"client_info",
|
|
11
|
+
"client_binding",
|
|
12
|
+
"discovery",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type McpOAuthCredentialLeaf =
|
|
16
|
+
(typeof MCP_OAUTH_CREDENTIAL_LEAVES)[number];
|
|
17
|
+
|
|
18
|
+
export type McpOAuthCredentialTarget =
|
|
19
|
+
| {
|
|
20
|
+
readonly source: "workspace";
|
|
21
|
+
readonly serverId: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
readonly source: "plugin";
|
|
25
|
+
readonly pluginName: string;
|
|
26
|
+
readonly serverKey: string;
|
|
27
|
+
readonly transportType: "sse" | "streamable-http";
|
|
28
|
+
readonly url: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function workspaceMcpOAuthCredentialTarget(
|
|
32
|
+
serverId: string,
|
|
33
|
+
): McpOAuthCredentialTarget {
|
|
34
|
+
return { source: "workspace", serverId };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveMcpOAuthCredentialTarget(
|
|
38
|
+
serverId: string,
|
|
39
|
+
config: ResolvedMcpServerConfig,
|
|
40
|
+
): McpOAuthCredentialTarget | null {
|
|
41
|
+
const transport = config.transport;
|
|
42
|
+
if (transport.type === "stdio") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (config.source === "workspace") {
|
|
46
|
+
return workspaceMcpOAuthCredentialTarget(serverId);
|
|
47
|
+
}
|
|
48
|
+
return pluginMcpOAuthCredentialTarget(
|
|
49
|
+
config.pluginName,
|
|
50
|
+
config.serverKey,
|
|
51
|
+
transport,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pluginMcpOAuthCredentialTarget(
|
|
56
|
+
pluginName: string,
|
|
57
|
+
serverKey: string,
|
|
58
|
+
transport: Extract<McpTransport, { type: "sse" | "streamable-http" }>,
|
|
59
|
+
): McpOAuthCredentialTarget {
|
|
60
|
+
return {
|
|
61
|
+
source: "plugin",
|
|
62
|
+
pluginName,
|
|
63
|
+
serverKey,
|
|
64
|
+
transportType: transport.type,
|
|
65
|
+
url: transport.url,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function mcpOAuthCredentialKey(
|
|
70
|
+
target: McpOAuthCredentialTarget,
|
|
71
|
+
leaf: McpOAuthCredentialLeaf,
|
|
72
|
+
): string {
|
|
73
|
+
if (target.source === "workspace") {
|
|
74
|
+
return `mcp:${target.serverId}:${leaf}`;
|
|
75
|
+
}
|
|
76
|
+
return `${pluginMcpOAuthCredentialPrefix(target.pluginName, target.serverKey, target.transportType, target.url)}${leaf}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pluginMcpOAuthCredentialPrefix(
|
|
80
|
+
pluginName: string,
|
|
81
|
+
serverKey?: string,
|
|
82
|
+
transportType?: "sse" | "streamable-http",
|
|
83
|
+
rawUrl?: string,
|
|
84
|
+
): string {
|
|
85
|
+
const pluginPrefix = `mcp-plugin/v1/${encodeSegment(pluginName)}/`;
|
|
86
|
+
if (serverKey === undefined) {
|
|
87
|
+
return pluginPrefix;
|
|
88
|
+
}
|
|
89
|
+
if (transportType === undefined || rawUrl === undefined) {
|
|
90
|
+
throw new Error("Plugin MCP credential endpoint is incomplete");
|
|
91
|
+
}
|
|
92
|
+
const endpoint = `${transportType}\n${canonicalUrlWithoutFragment(rawUrl)}`;
|
|
93
|
+
const endpointDigest = createHash("sha256").update(endpoint).digest("hex");
|
|
94
|
+
return `${pluginPrefix}${encodeSegment(serverKey)}/${endpointDigest}/`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function encodeSegment(value: string): string {
|
|
98
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function canonicalUrlWithoutFragment(rawUrl: string): string {
|
|
102
|
+
const url = new URL(rawUrl);
|
|
103
|
+
url.hash = "";
|
|
104
|
+
return url.toString();
|
|
105
|
+
}
|
|
@@ -42,6 +42,25 @@ let lastBuiltPluginFingerprint: string | null = null;
|
|
|
42
42
|
export function buildEffectiveMcpConfig(
|
|
43
43
|
workspaceConfig?: McpConfig,
|
|
44
44
|
): ResolvedMcpConfig {
|
|
45
|
+
const result = resolveEffectiveMcpConfig(workspaceConfig, true);
|
|
46
|
+
lastBuiltPluginFingerprint = fingerprintServers(result.pluginServers);
|
|
47
|
+
return result.config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve the effective config without recording it as applied by runtime. */
|
|
51
|
+
export function readEffectiveMcpConfig(
|
|
52
|
+
workspaceConfig?: McpConfig,
|
|
53
|
+
): ResolvedMcpConfig {
|
|
54
|
+
return resolveEffectiveMcpConfig(workspaceConfig, false).config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveEffectiveMcpConfig(
|
|
58
|
+
workspaceConfig: McpConfig | undefined,
|
|
59
|
+
reportIssues: boolean,
|
|
60
|
+
): {
|
|
61
|
+
config: ResolvedMcpConfig;
|
|
62
|
+
pluginServers: ReturnType<typeof readPluginMcpServers>["servers"];
|
|
63
|
+
} {
|
|
45
64
|
const base = workspaceConfig ?? McpConfigSchema.parse({});
|
|
46
65
|
const servers: Record<string, ResolvedMcpServerConfig> = {};
|
|
47
66
|
for (const [id, config] of Object.entries(base.servers)) {
|
|
@@ -49,30 +68,32 @@ export function buildEffectiveMcpConfig(
|
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
const { servers: pluginServers, issues } = readPluginMcpServers();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
if (reportIssues) {
|
|
72
|
+
for (const issue of issues) {
|
|
73
|
+
log.warn(
|
|
74
|
+
{
|
|
75
|
+
plugin: issue.pluginName,
|
|
76
|
+
...(issue.serverKey && { serverKey: issue.serverKey }),
|
|
77
|
+
},
|
|
78
|
+
`Plugin MCP declaration problem: ${issue.message}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
for (const server of pluginServers) {
|
|
63
84
|
if (Object.hasOwn(servers, server.id)) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
if (reportIssues) {
|
|
86
|
+
log.warn(
|
|
87
|
+
{ plugin: server.pluginName, serverId: server.id },
|
|
88
|
+
"Plugin MCP server shadowed by a workspace server of the same id; skipping",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
68
91
|
continue;
|
|
69
92
|
}
|
|
70
93
|
servers[server.id] = server.config;
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return { ...base, servers };
|
|
96
|
+
return { config: { ...base, servers }, pluginServers };
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
/**
|
|
@@ -98,6 +119,12 @@ export function pluginMcpServersChangedSinceLastBuild(): boolean {
|
|
|
98
119
|
*/
|
|
99
120
|
function fingerprintPluginServers(): string {
|
|
100
121
|
const { servers } = readPluginMcpServers();
|
|
122
|
+
return fingerprintServers(servers);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fingerprintServers(
|
|
126
|
+
servers: ReturnType<typeof readPluginMcpServers>["servers"],
|
|
127
|
+
): string {
|
|
101
128
|
return JSON.stringify(
|
|
102
129
|
[...servers]
|
|
103
130
|
.sort((a, b) => a.id.localeCompare(b.id))
|
package/src/mcp/manager.ts
CHANGED
|
@@ -15,9 +15,26 @@ export interface McpServerToolInfo {
|
|
|
15
15
|
tools: McpToolInfo[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type McpConnectionState =
|
|
19
|
+
| "connecting"
|
|
20
|
+
| "connected"
|
|
21
|
+
| "needs-auth"
|
|
22
|
+
| "error";
|
|
23
|
+
|
|
24
|
+
export type McpUnexpectedCloseHandler = () => void;
|
|
25
|
+
|
|
18
26
|
export class McpServerManager {
|
|
19
27
|
private clients = new Map<string, McpClient>();
|
|
20
|
-
private
|
|
28
|
+
private connectionStates = new Map<
|
|
29
|
+
string,
|
|
30
|
+
{ source: ResolvedMcpServerConfig["source"]; state: McpConnectionState }
|
|
31
|
+
>();
|
|
32
|
+
|
|
33
|
+
constructor(private onUnexpectedClose?: McpUnexpectedCloseHandler) {}
|
|
34
|
+
|
|
35
|
+
setUnexpectedCloseHandler(handler: McpUnexpectedCloseHandler): void {
|
|
36
|
+
this.onUnexpectedClose = handler;
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
async start(config: ResolvedMcpConfig): Promise<McpServerToolInfo[]> {
|
|
23
40
|
const results: McpServerToolInfo[] = [];
|
|
@@ -26,6 +43,10 @@ export class McpServerManager {
|
|
|
26
43
|
`[MCP] Starting ${Object.keys(config.servers).length} server(s)...`,
|
|
27
44
|
);
|
|
28
45
|
for (const [serverId, serverConfig] of Object.entries(config.servers)) {
|
|
46
|
+
this.connectionStates.set(serverId, {
|
|
47
|
+
source: serverConfig.source,
|
|
48
|
+
state: "connecting",
|
|
49
|
+
});
|
|
29
50
|
try {
|
|
30
51
|
console.log(
|
|
31
52
|
`[MCP] Starting server "${serverId}" (transport: ${serverConfig.transport.type})`,
|
|
@@ -39,20 +60,37 @@ export class McpServerManager {
|
|
|
39
60
|
"HTTP transport: OAuth provider will be available if server requires authentication",
|
|
40
61
|
);
|
|
41
62
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
const client = new McpClient(serverId, serverConfig, () => {
|
|
64
|
+
if (this.clients.get(serverId) !== client) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.connectionStates.set(serverId, {
|
|
68
|
+
source: serverConfig.source,
|
|
69
|
+
state: "error",
|
|
70
|
+
});
|
|
71
|
+
this.onUnexpectedClose?.();
|
|
72
|
+
});
|
|
45
73
|
await client.connect(serverConfig.transport);
|
|
46
74
|
|
|
47
75
|
if (!client.isConnected) {
|
|
48
|
-
|
|
76
|
+
this.connectionStates.set(serverId, {
|
|
77
|
+
source: serverConfig.source,
|
|
78
|
+
state: client.lastError ? "error" : "needs-auth",
|
|
79
|
+
});
|
|
49
80
|
continue;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
this.clients.set(serverId, client);
|
|
53
|
-
this.serverConfigs.set(serverId, serverConfig);
|
|
54
84
|
|
|
55
85
|
let tools = await client.listTools();
|
|
86
|
+
if (!client.isConnected || this.clients.get(serverId) !== client) {
|
|
87
|
+
this.clients.delete(serverId);
|
|
88
|
+
this.connectionStates.set(serverId, {
|
|
89
|
+
source: serverConfig.source,
|
|
90
|
+
state: "error",
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
56
94
|
log.info(
|
|
57
95
|
{ serverId, toolCount: tools.length },
|
|
58
96
|
"MCP server tools discovered",
|
|
@@ -71,7 +109,15 @@ export class McpServerManager {
|
|
|
71
109
|
}
|
|
72
110
|
|
|
73
111
|
results.push({ serverId, serverConfig, tools });
|
|
112
|
+
this.connectionStates.set(serverId, {
|
|
113
|
+
source: serverConfig.source,
|
|
114
|
+
state: "connected",
|
|
115
|
+
});
|
|
74
116
|
} catch (err) {
|
|
117
|
+
this.connectionStates.set(serverId, {
|
|
118
|
+
source: serverConfig.source,
|
|
119
|
+
state: "error",
|
|
120
|
+
});
|
|
75
121
|
console.error(`[MCP] Failed to connect to server "${serverId}":`, err);
|
|
76
122
|
log.error({ err, serverId }, "Failed to connect to MCP server");
|
|
77
123
|
// Clean up any partially-connected client
|
|
@@ -83,7 +129,6 @@ export class McpServerManager {
|
|
|
83
129
|
/* ignore */
|
|
84
130
|
}
|
|
85
131
|
this.clients.delete(serverId);
|
|
86
|
-
this.serverConfigs.delete(serverId);
|
|
87
132
|
}
|
|
88
133
|
}
|
|
89
134
|
}
|
|
@@ -119,7 +164,7 @@ export class McpServerManager {
|
|
|
119
164
|
);
|
|
120
165
|
await Promise.all(disconnects);
|
|
121
166
|
this.clients.clear();
|
|
122
|
-
this.
|
|
167
|
+
this.connectionStates.clear();
|
|
123
168
|
log.info("All MCP servers disconnected");
|
|
124
169
|
}
|
|
125
170
|
|
|
@@ -139,6 +184,14 @@ export class McpServerManager {
|
|
|
139
184
|
getClient(serverId: string): McpClient | undefined {
|
|
140
185
|
return this.clients.get(serverId);
|
|
141
186
|
}
|
|
187
|
+
|
|
188
|
+
getServerState(
|
|
189
|
+
serverId: string,
|
|
190
|
+
source: ResolvedMcpServerConfig["source"],
|
|
191
|
+
): McpConnectionState | undefined {
|
|
192
|
+
const entry = this.connectionStates.get(serverId);
|
|
193
|
+
return entry?.source === source ? entry.state : undefined;
|
|
194
|
+
}
|
|
142
195
|
}
|
|
143
196
|
|
|
144
197
|
// Singleton instance
|
|
@@ -15,6 +15,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
15
15
|
|
|
16
16
|
import { reloadMcpServers } from "../daemon/mcp-reload-service.js";
|
|
17
17
|
import { getLogger } from "../util/logger.js";
|
|
18
|
+
import {
|
|
19
|
+
mcpOAuthCredentialKey,
|
|
20
|
+
type McpOAuthCredentialTarget,
|
|
21
|
+
workspaceMcpOAuthCredentialTarget,
|
|
22
|
+
} from "./credential-target.js";
|
|
18
23
|
import {
|
|
19
24
|
setMcpAuthComplete,
|
|
20
25
|
setMcpAuthError,
|
|
@@ -46,8 +51,12 @@ export interface OrchestrateMcpOAuthConnectResult {
|
|
|
46
51
|
export async function orchestrateMcpOAuthConnect(args: {
|
|
47
52
|
serverId: string;
|
|
48
53
|
transport: McpAuthTransportConfig;
|
|
54
|
+
credentialTarget?: McpOAuthCredentialTarget;
|
|
49
55
|
}): Promise<OrchestrateMcpOAuthConnectResult> {
|
|
50
56
|
const { serverId, transport } = args;
|
|
57
|
+
const credentialTarget =
|
|
58
|
+
args.credentialTarget ?? workspaceMcpOAuthCredentialTarget(serverId);
|
|
59
|
+
const targetKey = mcpOAuthCredentialKey(credentialTarget, "tokens");
|
|
51
60
|
|
|
52
61
|
let capturedAuthUrl: string | undefined;
|
|
53
62
|
const provider = new McpOAuthProvider(
|
|
@@ -55,6 +64,7 @@ export async function orchestrateMcpOAuthConnect(args: {
|
|
|
55
64
|
transport.url,
|
|
56
65
|
/* interactive */ false,
|
|
57
66
|
{
|
|
67
|
+
credentialTarget,
|
|
58
68
|
onAuthorizationUrl: (url) => {
|
|
59
69
|
capturedAuthUrl = url;
|
|
60
70
|
},
|
|
@@ -72,7 +82,10 @@ export async function orchestrateMcpOAuthConnect(args: {
|
|
|
72
82
|
const { codePromise } = await provider.startCallbackServer();
|
|
73
83
|
|
|
74
84
|
// Resolve effective headers: credential store takes precedence, then config
|
|
75
|
-
const storedHeaders =
|
|
85
|
+
const storedHeaders =
|
|
86
|
+
credentialTarget.source === "workspace"
|
|
87
|
+
? await getMcpHeaders(serverId)
|
|
88
|
+
: null;
|
|
76
89
|
const effectiveHeaders = storedHeaders ?? transport.headers;
|
|
77
90
|
|
|
78
91
|
// Build the MCP transport and client
|
|
@@ -126,7 +139,7 @@ export async function orchestrateMcpOAuthConnect(args: {
|
|
|
126
139
|
// finishes cannot have its slot overwritten by stale completion writes
|
|
127
140
|
// from the older attempt.
|
|
128
141
|
const attemptId = crypto.randomUUID();
|
|
129
|
-
setMcpAuthPending(serverId, capturedAuthUrl, attemptId);
|
|
142
|
+
setMcpAuthPending(serverId, capturedAuthUrl, attemptId, targetKey);
|
|
130
143
|
|
|
131
144
|
// Fire-and-forget background tail — completes the token exchange once
|
|
132
145
|
// the user approves in the browser.
|
|
@@ -153,7 +166,7 @@ export async function orchestrateMcpOAuthConnect(args: {
|
|
|
153
166
|
}),
|
|
154
167
|
]);
|
|
155
168
|
await mcpTransport.finishAuth(code);
|
|
156
|
-
const applied = setMcpAuthComplete(serverId, attemptId);
|
|
169
|
+
const applied = setMcpAuthComplete(serverId, attemptId, targetKey);
|
|
157
170
|
if (!applied) {
|
|
158
171
|
log.info(
|
|
159
172
|
{ serverId, attemptId },
|
|
@@ -182,7 +195,7 @@ export async function orchestrateMcpOAuthConnect(args: {
|
|
|
182
195
|
}
|
|
183
196
|
} catch (err) {
|
|
184
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
-
const applied = setMcpAuthError(serverId, message, attemptId);
|
|
198
|
+
const applied = setMcpAuthError(serverId, message, attemptId, targetKey);
|
|
186
199
|
if (!applied) {
|
|
187
200
|
log.info(
|
|
188
201
|
{ serverId, attemptId, error: message },
|