Skip to content

Commit a170659

Browse files
authored
Add OAuth support for plugin MCP servers (#42804)
* Add OAuth support for plugin MCP servers * Localize plugin uninstall cleanup warnings
1 parent 4cba45d commit a170659

39 files changed

Lines changed: 1359 additions & 197 deletions

ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ This file is the cross-system architecture index. Detailed designs live in domai
3838
- Bundled-skill outbound API calls that require credentials use the Credential Execution Service (CES) tools (`make_authenticated_request`, `run_authenticated_command`) rather than manual token plumbing or proxied shell execution. See `assistant/docs/credential-execution-service.md`.
3939
- Managed shared-identity channel routing runs in a separate managed-gateway service lane from the per-assistant `gateway/` lane. The deployable managed-gateway runtime is platform-owned; this repo keeps public contracts/fixtures under `gateway-managed/`.
4040
- Production LLM calls go through the provider abstraction, not provider SDKs in feature code.
41+
- Plugin-declared remote MCP servers store OAuth records under `mcp-plugin/v1/<plugin>/<server>/<endpoint-digest>/<leaf>`, where plugin and server segments are base64url encoded and the SHA-256 endpoint digest covers the transport type plus the canonical URL without its fragment. The runtime public server id is presentation and routing state, not credential identity. Workspace MCP servers keep the existing `mcp:<serverId>:<leaf>` keys. This isolates plugin credentials by installed owner, original `mcp.json` key, and endpoint while leaving persisted workspace files unchanged. Plugin uninstall deletes the exact owner prefix when credential storage is reachable. If storage is unavailable, current `mcp.json` or install-fingerprint evidence blocks removal; without that evidence, removal succeeds with a warning because older credentials cannot be verified. An upgrade that removed both the declaration and its recorded fingerprint is indistinguishable from a plugin that never declared MCP, so historical keys may remain after offline removal.
4142
- The macOS and Windows Electron shells share platform-neutral window security, IPC validation, origin checks, and preload capability registration through `@vellumai/electron-desktop`, plus native helper process and JSON-RPC lifecycle through `@vellumai/native-sidecar`. Each client keeps platform lifecycle and native features in its own adapter modules under `clients/<platform>/src/`. Both preloads implement the same `VellumBridge` contract (`packages/ipc-contract`); a surface only one shell can back is optional there and documented in [`clients/windows/docs/parity-matrix.md`](clients/windows/docs/parity-matrix.md), which `clients/windows/src/preload/bridge-parity.test.ts` enforces against the macOS preload.
4243
- Packaged Windows startup provisions a user-scoped CLI runtime from
4344
`resources/cli-runtime`. Versioned installs and one fallback live under

assistant/openapi.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26544,6 +26544,12 @@ paths:
2654426544
target:
2654526545
type: string
2654626546
description: Absolute path that was removed on the assistant host. Useful for audit logs and confirmation toasts.
26547+
warnings:
26548+
description: Stable keys for non-fatal cleanup limitations that should be localized at the presentation edge.
26549+
type: array
26550+
items:
26551+
type: string
26552+
const: plugin.uninstall.mcp_oauth_credentials_unchecked
2654726553
required:
2654826554
- name
2654926555
- target

assistant/src/__tests__/mcp-auth-routes.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,29 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
33
// ── Module mocks (must precede imports) ───────────────────────────────────────
44

55
const mockReloadMcpServers = mock(async () => {});
6+
let pluginServers: Array<{
7+
id: string;
8+
pluginName: string;
9+
serverKey: string;
10+
config: {
11+
source: "plugin";
12+
pluginName: string;
13+
serverKey: string;
14+
transport: {
15+
type: "streamable-http";
16+
url: string;
17+
};
18+
};
19+
}> = [];
620

721
mock.module("../daemon/mcp-reload-service.js", () => ({
822
reloadMcpServers: () => mockReloadMcpServers(),
923
}));
1024

25+
mock.module("../plugins/mcp-servers.js", () => ({
26+
readPluginMcpServers: () => ({ servers: pluginServers, issues: [] }),
27+
}));
28+
1129
const mockOrchestrateConnect = mock(
1230
async (_args: { serverId: string; transport: unknown }) => ({
1331
auth_url: "https://provider.example.com/authorize?state=abc",
@@ -55,6 +73,7 @@ describe("mcp-auth-routes", () => {
5573
mockGetMcpAuthState.mockClear();
5674
mockGetMcpAuthState.mockImplementation(() => null);
5775
mockReloadMcpServers.mockClear(); // ← add this line
76+
pluginServers = [];
5877
});
5978

6079
describe("POST internal/mcp/auth/start", () => {
@@ -68,6 +87,86 @@ describe("mcp-auth-routes", () => {
6887
auth_url: "https://provider.example.com/authorize?state=abc",
6988
state: "my-server",
7089
});
90+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
91+
expect.objectContaining({
92+
serverId: "my-server",
93+
credentialTarget: {
94+
source: "workspace",
95+
serverId: "my-server",
96+
},
97+
}),
98+
);
99+
});
100+
101+
test("resolves an installed plugin server by its public id", async () => {
102+
pluginServers = [
103+
{
104+
id: "plugin-auth__remote",
105+
pluginName: "plugin-auth",
106+
serverKey: "remote",
107+
config: {
108+
source: "plugin",
109+
pluginName: "plugin-auth",
110+
serverKey: "remote",
111+
transport: {
112+
type: "streamable-http",
113+
url: "https://mcp.example.com/plugin",
114+
},
115+
},
116+
},
117+
];
118+
119+
const startRoute = findRoute("internal_mcp_auth_start");
120+
await startRoute.handler({
121+
body: { serverId: "plugin-auth__remote" },
122+
});
123+
124+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
125+
expect.objectContaining({
126+
serverId: "plugin-auth__remote",
127+
credentialTarget: {
128+
source: "plugin",
129+
pluginName: "plugin-auth",
130+
serverKey: "remote",
131+
transportType: "streamable-http",
132+
url: "https://mcp.example.com/plugin",
133+
},
134+
}),
135+
);
136+
});
137+
138+
test("keeps workspace precedence when a plugin declares the same id", async () => {
139+
pluginServers = [
140+
{
141+
id: "my-server",
142+
pluginName: "my-server",
143+
serverKey: "my-server",
144+
config: {
145+
source: "plugin",
146+
pluginName: "my-server",
147+
serverKey: "my-server",
148+
transport: {
149+
type: "streamable-http",
150+
url: "https://loses.example.com/mcp",
151+
},
152+
},
153+
},
154+
];
155+
156+
const startRoute = findRoute("internal_mcp_auth_start");
157+
await startRoute.handler({ body: { serverId: "my-server" } });
158+
159+
expect(mockOrchestrateConnect).toHaveBeenCalledWith(
160+
expect.objectContaining({
161+
transport: expect.objectContaining({
162+
url: "https://mcp.example.com",
163+
}),
164+
credentialTarget: {
165+
source: "workspace",
166+
serverId: "my-server",
167+
},
168+
}),
169+
);
71170
});
72171

73172
test("rejects unknown serverId with BadRequestError", async () => {

assistant/src/__tests__/mcp-list-plugin-servers.test.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@
88
*
99
* 1. A workspace entry of the same id wins. Getting precedence backwards
1010
* 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.
11+
* 2. A plugin server is never health-checked as a side effect of listing.
12+
* OAuth status is read from the plugin and endpoint-scoped credential
13+
* identity, while workspace static headers remain isolated.
1614
*/
1715

1816
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
1917
import { join } from "node:path";
2018
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
2119

2220
const getServerState = jest.fn();
21+
const hasMcpOAuthTokens = jest.fn(async (target: any) => {
22+
if (target.source === "plugin" && target.url === "not a url") {
23+
throw new TypeError("invalid URL");
24+
}
25+
return true;
26+
});
2327

2428
mock.module("../mcp/client.js", () => ({
2529
McpClient: class {
@@ -45,9 +49,7 @@ mock.module("../mcp/mcp-auth-state.js", () => ({
4549
}));
4650

4751
mock.module("../mcp/mcp-oauth-provider.js", () => ({
48-
// Stand in for a credential store that holds tokens for every id, which
49-
// is the condition under which a leak would be observable.
50-
hasMcpOAuthTokens: async () => true,
52+
hasMcpOAuthTokens,
5153
deleteMcpOAuthCredentials: async () => ({ ok: true, failedKeys: [] }),
5254
}));
5355

@@ -130,6 +132,7 @@ async function listServers(): Promise<ListedServer[]> {
130132
describe("internal_mcp_list, plugin-declared servers", () => {
131133
beforeEach(() => {
132134
getServerState.mockReset();
135+
hasMcpOAuthTokens.mockClear();
133136
rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
134137
mkdirSync(getWorkspacePluginsDir(), { recursive: true });
135138
});
@@ -198,13 +201,21 @@ describe("internal_mcp_list, plugin-declared servers", () => {
198201
expect(plugin.status).toBe("connected");
199202
});
200203

201-
test("plugin servers report no assistant-owned auth even when the store has some", async () => {
204+
test("plugin servers report endpoint-scoped OAuth without static auth", async () => {
202205
writePlugin("unabyss", unabyssManifest());
203206

204207
const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
205-
expect(plugin.hasOAuth).toBe(false);
208+
expect(plugin.hasOAuth).toBe(true);
206209
expect(plugin.hasStaticAuth).toBe(false);
207210
expect(plugin.authType).toEqual("none");
211+
expect(hasMcpOAuthTokens).toHaveBeenCalledWith(
212+
expect.objectContaining({
213+
source: "plugin",
214+
pluginName: "unabyss",
215+
serverKey: "unabyss",
216+
url: "https://mcp.unabyss.com",
217+
}),
218+
);
208219
});
209220

210221
test("workspace servers keep reporting their auth state", async () => {
@@ -245,6 +256,20 @@ describe("internal_mcp_list, plugin-declared servers", () => {
245256
expect(ids).toContain("from-workspace");
246257
});
247258

259+
test("an invalid plugin transport URL does not break the listing", async () => {
260+
writePlugin("bad-url", {
261+
mcpServers: {
262+
remote: { type: "streamable-http", url: "not a url" },
263+
},
264+
});
265+
266+
const servers = await listServers();
267+
expect(servers.find((s) => s.id === "bad-url__remote")?.hasOAuth).toBe(
268+
false,
269+
);
270+
expect(servers.some((s) => s.id === "from-workspace")).toBe(true);
271+
});
272+
248273
test("no plugins installed leaves the listing unchanged", async () => {
249274
const servers = await listServers();
250275
expect(servers.every((s) => s.source === "workspace")).toBe(true);

assistant/src/__tests__/mcp-tool-annotations-risk.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ const { RiskLevel } = await import("../permissions/types.js");
3232
type ServerSource = "workspace" | "plugin";
3333

3434
function serverConfig(source: ServerSource) {
35-
return {
35+
const base = {
3636
transport: { type: "stdio" as const, command: "echo", args: [] },
37-
source,
3837
};
38+
return source === "workspace"
39+
? { ...base, source }
40+
: { ...base, source, pluginName: "plugin", serverKey: "server" };
3941
}
4042

4143
interface RiskAnnotations {

assistant/src/cli/commands/plugins.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
PluginAlreadyInStateException,
5454
PluginDirectoryNotFoundError,
5555
} from "../lib/toggle-plugin.js";
56+
import type { PluginUninstallWarningKey } from "../lib/uninstall-plugin.js";
5657
import type { PluginUpgradeResult } from "../lib/upgrade-plugin.js";
5758
import { getCliLogger } from "../logger.js";
5859
import { PLUGINS_SEARCH_INSTALL_HINT, pluginsHelp } from "./plugins.help.js";
@@ -666,11 +667,16 @@ export function registerPluginsCommand(program: Command): void {
666667
// when the daemon is unreachable (a transport error carries no
667668
// `statusCode`); an operator can still uninstall while it's stopped,
668669
// and `shutdown` then runs in this process, the only one available.
669-
const daemon = await cliIpcCall<{ name: string; target: string }>(
670-
"plugins_uninstall",
671-
{ pathParams: { name } },
672-
);
673-
let result: { name: string; target: string };
670+
const daemon = await cliIpcCall<{
671+
name: string;
672+
target: string;
673+
warnings?: PluginUninstallWarningKey[];
674+
}>("plugins_uninstall", { pathParams: { name } });
675+
let result: {
676+
name: string;
677+
target: string;
678+
warnings?: PluginUninstallWarningKey[];
679+
};
674680
if (daemon.ok && daemon.result) {
675681
result = daemon.result;
676682
} else if (daemon.statusCode === undefined) {
@@ -693,6 +699,11 @@ export function registerPluginsCommand(program: Command): void {
693699
console.log(
694700
`Uninstalled plugin "${result.name}" from ${result.target}`,
695701
);
702+
for (const warning of result.warnings ?? []) {
703+
console.warn(
704+
`Warning: ${libs.uninstall.resolvePluginUninstallWarning(warning)}`,
705+
);
706+
}
696707
} catch (err) {
697708
if (err instanceof libs.installGitHub.InvalidPluginNameError) {
698709
console.error(err.message);

0 commit comments

Comments
 (0)