Compare commits

...

5 Commits

Author SHA1 Message Date
Saoud Rizwan ae65f2230c feat: sync plugin MCP servers to settings 2026-06-15 13:03:42 -07:00
Saoud Rizwan 249dfde4bf fix: address plugin MCP review comments 2026-06-12 17:21:37 -07:00
Saoud Rizwan ed2478a0d6 Merge branch 'main' into saoudrizwan/plugin-mcp-support 2026-06-12 17:17:18 -07:00
Saoud Rizwan e240132ff5 Merge branch 'main' into saoudrizwan/plugin-mcp-support 2026-06-12 16:37:05 -07:00
Saoud Rizwan d3fc7155fa feat: add MCP support to plugins 2026-06-12 16:31:22 -07:00
26 changed files with 1924 additions and 153 deletions
+12 -2
View File
@@ -21,7 +21,11 @@ import {
resolve,
sep,
} from "node:path";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
type PluginUninstallOptions,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -1071,11 +1075,17 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
return {
const result = {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
};
await syncPluginMcpServersToSettings({
pluginPaths: result.entryPaths,
cwd,
workspacePath: cwd,
});
return result;
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
@@ -43,9 +43,17 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -76,6 +84,28 @@ describe("interactive config data loader", () => {
return pluginPath;
}
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-mcp-plugin',",
" manifest: { capabilities: ['mcp'] },",
" setup(api) {",
" api.registerMcpServer({",
" name: 'smoke',",
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -311,6 +341,70 @@ Find installable skills.`,
).toBe(true);
});
it("loads plugin-owned MCP servers from settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.mcp.some(
(item) =>
item.name === "smoke" &&
item.pluginName === "settings-mcp-plugin" &&
item.pluginPath === pluginPath &&
item.kind === "mcp",
),
).toBe(true);
});
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -731,6 +825,81 @@ Review with the bundled skill.`,
).toBe(false);
});
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const item: InteractiveConfigItem = {
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
};
await loader.onToggleConfigItem(item);
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
await loader.onToggleConfigItem({ ...item, enabled: false });
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it("surfaces MCP OAuth status and errors", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,7 +1,9 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -71,6 +73,17 @@ export function createInteractiveConfigDataLoader(input: {
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
} else {
await syncPluginMcpServersToSettings({
pluginPaths: [item.path],
cwd: input.config.cwd,
workspacePath: workspaceRoot(),
providerId: input.config.providerId,
modelId: input.config.modelId,
});
}
return undefined;
}
+26 -6
View File
@@ -62,9 +62,10 @@ export interface InteractiveConfigItem {
kind: InteractiveConfigItemKind;
enabledState?: "enabled" | "disabled" | "partial";
toolNames?: string[];
configKind?: "tool" | "plugin";
configKind?: "tool" | "plugin" | "plugin-mcp";
pluginName?: string;
pluginPath?: string;
mcpServerName?: string;
loadError?: string;
loadErrorPhase?: PluginInitializationFailure["phase"];
source:
@@ -86,6 +87,7 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -93,12 +95,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source">,
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
): boolean {
if (item.kind === "mcp") {
return !item.pluginName;
}
return (
item.kind === "skill" ||
item.kind === "plugin" ||
item.kind === "mcp" ||
item.source === "builtin" ||
item.source === "workspace-plugin" ||
item.source === "global-plugin"
@@ -242,9 +246,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
}
}
function getPluginDisplayName(filePath: string): string {
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
for (let depth = 0; depth < 4; depth++) {
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
@@ -384,7 +389,7 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
kind: "plugin",
@@ -458,6 +463,16 @@ export async function loadInteractiveConfigData(input: {
for (const registration of resolveMcpServerRegistrations({
filePath: mcpSettingsPath,
})) {
const pluginName =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginName === "string"
? registration.metadata.pluginName
: undefined;
const pluginPath =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginPath === "string"
? registration.metadata.pluginPath
: undefined;
mcp.push({
id: registration.name,
name: registration.name,
@@ -467,6 +482,8 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -514,6 +531,8 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
mcpServerName: pluginTool.mcpServerName,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -533,5 +552,6 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
@@ -229,3 +229,18 @@ export function getConfigFooterText({
export function getConfigItemDisplayName(name: string): string {
return name;
}
export function getPluginDiagnosticsLoadingText(
tab: InteractiveConfigTab,
): string | undefined {
if (tab === "mcp") {
return "Loading plugin MCP servers...";
}
if (tab === "tools") {
return "Loading plugin tools...";
}
if (tab === "plugins") {
return "Loading plugin diagnostics...";
}
return undefined;
}
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
});
it("does not treat plugin MCP rows as toggleable", () => {
expect(
isToggleableConfigItem(
createItem({
kind: "mcp",
pluginName: "plugin",
source: "workspace-plugin",
}),
),
).toBe(false);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+93 -23
View File
@@ -25,6 +25,7 @@ import {
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -192,13 +193,16 @@ function appendToolGroupRows(
const enabledCount = groupItems.filter(
(item) => item.enabled !== false,
).length;
const nativeItems = groupItems.filter((item) => !item.mcpServerName);
const mcpItems = groupItems.filter((item) => item.mcpServerName);
rows.push({
kind: "tool-group",
label: first?.pluginName ?? "plugin",
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
for (const item of sortBySourceThenName(nativeItems)) {
rows.push({
kind: "ext",
name: item.name,
@@ -211,6 +215,46 @@ function appendToolGroupRows(
rightLabel: sharedToolNames.has(item.name) ? "shared tool name" : "",
});
}
if (mcpItems.length === 0) {
continue;
}
rows.push({
kind: "tool-group",
label: "MCP",
rightLabel: `${mcpItems.filter((item) => item.enabled !== false).length}/${mcpItems.length} tools enabled`,
indent: 4,
});
const byServer = new Map<string, InteractiveConfigItem[]>();
for (const item of mcpItems) {
const serverName = item.mcpServerName ?? "MCP server";
const serverItems = byServer.get(serverName) ?? [];
serverItems.push(item);
byServer.set(serverName, serverItems);
}
for (const [serverName, serverItems] of [...byServer.entries()].sort(
(left, right) => left[0].localeCompare(right[0]),
)) {
rows.push({
kind: "tool-group",
label: serverName,
rightLabel: `${serverItems.filter((item) => item.enabled !== false).length}/${serverItems.length} tools enabled`,
indent: 6,
});
for (const item of sortBySourceThenName(serverItems)) {
rows.push({
kind: "ext",
name: item.name,
path: item.path,
source: item.source,
enabled: item.enabled,
description: item.description,
item,
indent: 8,
rightLabel: sharedToolNames.has(item.name) ? "shared tool name" : "",
});
}
}
}
}
@@ -245,17 +289,26 @@ function appendToolRows(
appendExtRows(rows, builtinTools);
}
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
const pluginToolItems = items.filter((item) => item.pluginName);
const pluginGroups = groupToolItems(pluginToolItems);
if (pluginGroups.length > 0) {
rows.push({ kind: "head", label: "Plugins" });
appendToolGroupRows(
rows,
pluginGroups,
getSharedToolNames(items.filter((item) => item.pluginName)),
getSharedToolNames(pluginToolItems),
);
}
}
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
return (
data.pluginDiagnosticsLoaded ||
data.tools.some((item) => item.pluginName) ||
data.mcp.some((item) => item.pluginName)
);
}
function appendSkillRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
@@ -305,11 +358,18 @@ function withOptimisticToggle(
).filter(Boolean),
);
const updateItems = (items: InteractiveConfigItem[]) =>
items.map((candidate) =>
matchesItem(candidate)
? { ...candidate, enabled: nextEnabled }
: candidate,
);
items.map((candidate) => {
if (matchesItem(candidate)) {
return { ...candidate, enabled: nextEnabled };
}
if (
item.kind === "plugin" &&
(candidate.path === item.path || candidate.pluginPath === item.path)
) {
return { ...candidate, enabled: nextEnabled };
}
return candidate;
});
const updateTools = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
@@ -381,7 +441,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
props.configData.tools.some((item) => item.pluginName),
hasPluginDiagnostics(props.configData),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
@@ -399,7 +459,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
useEffect(() => {
if (
(activeTab !== "tools" && activeTab !== "plugins") ||
(activeTab !== "tools" &&
activeTab !== "plugins" &&
activeTab !== "mcp") ||
pluginToolsLoaded ||
pluginToolsError ||
!loadConfigData
@@ -465,10 +527,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
if (pluginToolsLoading && loadingText) {
r.push({
kind: "detail",
text: "Loading plugin tools...",
text: loadingText,
});
}
if (pluginToolsError) {
@@ -491,20 +554,29 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
item,
rightLabel:
activeTab === "mcp"
? getMcpManagerEntryStatus({
description: item.description,
lastError: item.loadError,
})
? item.configKind === "plugin-mcp" && item.loadError
? getPluginLoadErrorLabel(item)
: getMcpManagerEntryStatus({
description: item.description,
lastError: item.loadError,
})
: getPluginLoadErrorLabel(item),
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
if (
(activeTab === "plugins" || activeTab === "mcp") &&
pluginToolsLoading
) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: "Loading plugin diagnostics...",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
if (
(activeTab === "plugins" || activeTab === "mcp") &&
pluginToolsError
) {
r.push({
kind: "detail",
text: pluginToolsError,
@@ -549,15 +621,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
}
} catch (error) {
+26 -18
View File
@@ -438,6 +438,7 @@ export interface DefaultMcpServerClientFactoryOptions {
clientName?: string;
clientVersion?: string;
fetch?: FetchLike;
enableOAuth?: boolean;
}
class SdkUrlMcpClient implements McpServerClient {
@@ -459,12 +460,16 @@ class SdkUrlMcpClient implements McpServerClient {
);
}
const authContext = createMcpOAuthProviderContext({
settingsPath: this.options.settingsPath,
serverName: this.registration.name,
redirectUrl:
this.registration.oauth?.redirectUrl ?? DEFAULT_HTTP_MCP_REDIRECT_URL,
});
const authContext =
this.options.enableOAuth === false
? undefined
: createMcpOAuthProviderContext({
settingsPath: this.options.settingsPath,
serverName: this.registration.name,
redirectUrl:
this.registration.oauth?.redirectUrl ??
DEFAULT_HTTP_MCP_REDIRECT_URL,
});
this.authContext = authContext;
try {
const client = new Client({
@@ -473,20 +478,20 @@ class SdkUrlMcpClient implements McpServerClient {
});
const transport = createMcpSdkTransport({
registration: this.registration,
oauthProvider: authContext.provider,
oauthProvider: authContext?.provider,
fetch: this.options.fetch,
});
await client.connect(transport);
await authContext.clearError();
await authContext?.clearError();
this.client = client;
} catch (error) {
const message =
error instanceof UnauthorizedError
? this.formatUnauthorizedMessage(
authContext.getLastAuthorizationUrl(),
authContext?.getLastAuthorizationUrl(),
)
: toErrorMessage(error);
await authContext.markError(message);
await authContext?.markError(message);
throw new Error(message);
}
}
@@ -554,17 +559,20 @@ class SdkUrlMcpClient implements McpServerClient {
private async handleOperationError(error: unknown): Promise<never> {
const authContext =
this.authContext ??
createMcpOAuthProviderContext({
settingsPath: this.options.settingsPath,
serverName: this.registration.name,
redirectUrl:
this.registration.oauth?.redirectUrl ?? DEFAULT_HTTP_MCP_REDIRECT_URL,
});
(this.options.enableOAuth === false
? undefined
: createMcpOAuthProviderContext({
settingsPath: this.options.settingsPath,
serverName: this.registration.name,
redirectUrl:
this.registration.oauth?.redirectUrl ??
DEFAULT_HTTP_MCP_REDIRECT_URL,
}));
const message =
error instanceof UnauthorizedError
? this.formatUnauthorizedMessage(authContext.getLastAuthorizationUrl())
? this.formatUnauthorizedMessage(authContext?.getLastAuthorizationUrl())
: toErrorMessage(error);
await authContext.markError(message);
await authContext?.markError(message);
throw new Error(message);
}
}
@@ -25,6 +25,11 @@ export type {
McpOAuthProviderContext,
} from "./oauth";
export { authorizeMcpServerOAuth } from "./oauth";
export type { PluginMcpServerResolution } from "./plugin-server-registration";
export {
normalizePluginMcpServerRegistration,
resolvePluginMcpServerRegistrations,
} from "./plugin-server-registration";
export type {
CreateDisabledMcpToolPoliciesOptions,
CreateDisabledMcpToolPolicyOptions,
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { normalizePluginMcpServerRegistration } from "./plugin-server-registration";
describe("plugin MCP server registration", () => {
it("normalizes streamable HTTP plugin MCP servers", () => {
const result = normalizePluginMcpServerRegistration({
name: "remote",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
},
});
expect(result.loadError).toBeUndefined();
expect(result.registration).toEqual({
name: "remote",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
},
});
});
it("normalizes SSE plugin MCP servers", () => {
const result = normalizePluginMcpServerRegistration({
name: "remote-sse",
transport: {
type: "sse",
url: "https://example.com/sse",
},
});
expect(result.loadError).toBeUndefined();
expect(result.registration).toEqual({
name: "remote-sse",
transport: {
type: "sse",
url: "https://example.com/sse",
},
});
});
it("keeps top-level env scoped to stdio transports", () => {
const result = normalizePluginMcpServerRegistration({
name: "remote",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
},
env: {
TOKEN: {
fromEnv: "TOKEN",
required: true,
},
},
});
expect(result.registration).toBeUndefined();
expect(result.loadError).toContain(
"top-level env is only supported for stdio MCP transports",
);
});
it("reports missing stdio command before missing required env", () => {
const result = normalizePluginMcpServerRegistration({
name: "local",
transport: {
type: "stdio",
command: "",
},
env: {
MISSING_TOKEN: {
fromEnv: "CLINE_TEST_MISSING_PLUGIN_MCP_TOKEN",
required: true,
},
},
});
expect(result.registration).toBeUndefined();
expect(result.loadError).toBe("stdio MCP transport requires command");
});
it("normalizes whitespace-only server names to empty-name errors", () => {
const result = normalizePluginMcpServerRegistration({
name: " ",
transport: {
type: "stdio",
command: "node",
},
});
expect(result.registration).toBeUndefined();
expect(result).toEqual({
name: "",
loadError: "empty MCP server name",
});
});
});
@@ -0,0 +1,260 @@
import type {
AgentExtensionMcpEnvValue,
AgentExtensionMcpServer,
} from "@cline/shared";
import type { McpServerRegistration } from "./types";
export interface PluginMcpServerResolution<TOwner> {
owner: TOwner;
name: string;
registration?: McpServerRegistration;
loadError?: string;
}
type ResolvedPluginMcpEnv =
| {
ok: true;
env?: Record<string, string>;
}
| {
ok: false;
reason: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return (
isRecord(value) &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((entry) => typeof entry === "string")
);
}
function isPluginMcpEnvValue(
value: unknown,
): value is AgentExtensionMcpEnvValue {
if (!isRecord(value)) {
return false;
}
return (
(value.fromEnv === undefined || typeof value.fromEnv === "string") &&
(value.value === undefined || typeof value.value === "string") &&
(value.required === undefined || typeof value.required === "boolean")
);
}
function resolvePluginMcpEnv(
server: AgentExtensionMcpServer,
): ResolvedPluginMcpEnv {
const entries = server.env ? Object.entries(server.env) : [];
if (entries.length === 0) {
return { ok: true };
}
const env: Record<string, string> = {};
for (const [targetName, value] of entries) {
if (typeof value === "string") {
env[targetName] = value;
continue;
}
const sourceName = value.fromEnv?.trim() || targetName;
const sourceValue = process.env[sourceName];
if (typeof sourceValue === "string" && sourceValue.length > 0) {
env[targetName] = sourceValue;
continue;
}
if (typeof value.value === "string") {
env[targetName] = value.value;
continue;
}
if (value.required === true) {
return {
ok: false,
reason: `required environment variable "${sourceName}" is not set`,
};
}
}
return { ok: true, env: Object.keys(env).length > 0 ? env : undefined };
}
export function normalizePluginMcpServerRegistration(
server: AgentExtensionMcpServer,
): {
name: string;
registration?: McpServerRegistration;
loadError?: string;
} {
if (!isRecord(server)) {
return { name: "", loadError: "invalid MCP server registration" };
}
const name = typeof server.name === "string" ? server.name.trim() : "";
if (!name) {
return {
name,
loadError: "empty MCP server name",
};
}
const envValue = server.env;
const env = envValue === undefined ? undefined : envValue;
if (env !== undefined) {
if (!isRecord(env)) {
return { name, loadError: "invalid env" };
}
for (const [key, value] of Object.entries(env)) {
if (typeof value !== "string" && !isPluginMcpEnvValue(value)) {
return { name, loadError: `invalid env "${key}"` };
}
}
}
const transport = server.transport;
if (!isRecord(transport)) {
return { name, loadError: "invalid MCP transport" };
}
const type = transport.type;
if (type !== "stdio" && type !== "sse" && type !== "streamableHttp") {
return { name, loadError: "invalid MCP transport type" };
}
if (type !== "stdio" && env !== undefined) {
return {
name,
loadError: "top-level env is only supported for stdio MCP transports",
};
}
const metadata = isRecord(server.metadata) ? server.metadata : undefined;
if (type === "stdio") {
const command = transport.command;
if (typeof command !== "string" || !command.trim()) {
return { name, loadError: "stdio MCP transport requires command" };
}
const args = transport.args;
if (args !== undefined && !isStringArray(args)) {
return { name, loadError: "stdio MCP transport args must be strings" };
}
const cwd = transport.cwd;
if (cwd !== undefined && typeof cwd !== "string") {
return { name, loadError: "stdio MCP transport cwd must be a string" };
}
const transportEnv = transport.env;
if (transportEnv !== undefined && !isStringRecord(transportEnv)) {
return { name, loadError: "stdio MCP transport env must be strings" };
}
const resolvedEnv = resolvePluginMcpEnv({
name,
transport: {
type: "stdio",
command,
args,
cwd,
env: transportEnv,
},
env,
metadata,
});
if (!resolvedEnv.ok) {
return { name, loadError: resolvedEnv.reason };
}
const resolvedTransportEnv =
transportEnv || resolvedEnv.env
? {
...(transportEnv ?? {}),
...(resolvedEnv.env ?? {}),
}
: undefined;
return {
name,
registration: {
name,
transport: {
type: "stdio",
command,
args,
cwd,
env: resolvedTransportEnv,
},
metadata,
},
};
}
if (typeof transport.url !== "string" || !transport.url.trim()) {
return { name, loadError: `${type} MCP transport requires url` };
}
const headers = transport.headers;
if (headers !== undefined && !isStringRecord(headers)) {
return { name, loadError: `${type} MCP transport headers must be strings` };
}
return {
name,
registration: {
name,
transport:
type === "sse"
? {
type: "sse",
url: transport.url,
headers,
}
: {
type: "streamableHttp",
url: transport.url,
headers,
},
metadata,
},
};
}
export function resolvePluginMcpServerRegistrations<TOwner>(
servers: readonly {
server: AgentExtensionMcpServer;
owner: TOwner;
ownerLabel?: string;
}[],
): PluginMcpServerResolution<TOwner>[] {
const firstOwnerByName = new Map<string, string | undefined>();
return servers.map(({ server, owner, ownerLabel }) => {
const normalized = normalizePluginMcpServerRegistration(server);
if (!normalized.registration) {
return {
owner,
name: normalized.name,
loadError: normalized.loadError ?? "invalid MCP server registration",
};
}
const firstOwner = firstOwnerByName.get(normalized.registration.name);
if (firstOwnerByName.has(normalized.registration.name)) {
const ownerText = firstOwner
? ` already registered by ${firstOwner}`
: "";
return {
owner,
name: normalized.registration.name,
loadError: `duplicate MCP server name "${normalized.registration.name}"${ownerText}`,
};
}
firstOwnerByName.set(normalized.registration.name, ownerLabel);
return {
owner,
name: normalized.registration.name,
registration: normalized.registration,
};
});
}
@@ -11,6 +11,7 @@
*/
import {
type AgentExtensionMcpServer,
type AutomationEventEnvelope,
normalizePluginManifest,
type PluginManifest,
@@ -85,6 +86,7 @@ interface PluginApi {
registerMessageBuilder(builder: PluginMessageBuilder): void;
registerProvider(provider: PluginProvider): void;
registerAutomationEventType(eventType: PluginAutomationEventType): void;
registerMcpServer(server: AgentExtensionMcpServer): void;
}
interface PluginSetupCtx {
@@ -153,6 +155,7 @@ interface PluginDescriptor {
messageBuilders: ContributionDescriptor[];
providers: ContributionDescriptor[];
automationEventTypes: AutomationEventTypeDescriptor[];
mcpServers: AgentExtensionMcpServer[];
shortcuts?: ContributionDescriptor[];
flags?: ContributionDescriptor[];
};
@@ -437,6 +440,7 @@ async function loadPluginDescriptor(args: {
messageBuilders: [],
providers: [],
automationEventTypes: [],
mcpServers: [],
shortcuts: [],
flags: [],
};
@@ -509,6 +513,9 @@ async function loadPluginDescriptor(args: {
...normalizeAutomationEventType(eventType),
});
},
registerMcpServer: (server) => {
contributions.mcpServers.push(server);
},
};
if (typeof plugin.setup === "function") {
@@ -35,6 +35,7 @@ function createApiCapture() {
registerProvider: () => {},
registerAutomationEventType: (eventType: unknown) =>
automationEventTypes.push(eventType),
registerMcpServer: () => {},
};
return { tools, rules, messageBuilders, automationEventTypes, api };
}
@@ -6,6 +6,7 @@ import type {
AgentConfig,
AgentExtensionAutomationEventType,
AgentExtensionCommandResult,
AgentExtensionMcpServer,
AgentExtensionRule,
AgentRuntimeHooks,
AgentTool,
@@ -96,6 +97,7 @@ type SandboxedPluginDescriptor = {
messageBuilders: SandboxedContributionDescriptor[];
providers: SandboxedContributionDescriptor[];
automationEventTypes: SandboxedAutomationEventTypeDescriptor[];
mcpServers: AgentExtensionMcpServer[];
shortcuts?: SandboxedContributionDescriptor[];
flags?: SandboxedContributionDescriptor[];
};
@@ -118,6 +120,7 @@ function normalizeDescriptor(
providers: descriptor.contributions?.providers ?? [],
automationEventTypes:
descriptor.contributions?.automationEventTypes ?? [],
mcpServers: descriptor.contributions?.mcpServers ?? [],
shortcuts: descriptor.contributions?.shortcuts ?? [],
flags: descriptor.contributions?.flags ?? [],
},
@@ -531,6 +534,10 @@ function registerSimpleContributions(
metadata: eventType.metadata,
});
}
for (const mcpServer of descriptor.contributions?.mcpServers ?? []) {
api.registerMcpServer(mcpServer);
}
}
function registerMessageBuilders(
+11
View File
@@ -468,6 +468,17 @@ export {
toggleDisabledTool,
writeGlobalSettings,
} from "./services/global-settings";
export type {
PluginMcpSettingsMutation,
PluginMcpSettingsSyncResult,
RemovePluginMcpServersFromSettingsOptions,
SyncPluginMcpServersToSettingsOptions,
} from "./services/plugin-mcp-settings";
export {
disablePluginMcpServersInSettings,
removePluginMcpServersFromSettings,
syncPluginMcpServersToSettings,
} from "./services/plugin-mcp-settings";
export type {
ListPluginToolsResult,
PluginToolSummary,
@@ -243,6 +243,7 @@ describe("SessionRuntime construction", () => {
messageBuilder: [],
providers: [],
automationEventTypes: [],
mcpServers: [],
});
});
});
@@ -295,6 +296,44 @@ describe("SessionRuntime.getExtensionRegistry", () => {
expect(registry.commands).toHaveLength(1);
expect(registry.commands[0].name).toBe("ext-cmd");
expect(registry.automationEventTypes).toEqual([]);
expect(registry.mcpServers).toEqual([]);
});
it("keeps plugin-registered MCP servers out of direct runtime tools", async () => {
const extension: AgentExtension = {
name: "mcp-ext",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "plugin-mock",
transport: {
type: "stdio",
command: process.execPath,
},
});
},
};
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(
makeAgentConfig({ extensions: [extension] }),
deps,
);
await session.run("go");
expect((configs[0]?.tools ?? []).map((tool) => tool.name)).not.toContain(
"plugin-mock__echo",
);
expect(session.getExtensionRegistry().mcpServers).toEqual([
expect.objectContaining({
name: "plugin-mock",
metadata: {
source: "plugin",
plugin: "mcp-ext",
},
}),
]);
await session.shutdown("test");
});
it("composes extension-registered rules into the runtime system prompt", async () => {
@@ -46,6 +46,7 @@ import {
type ModelInfo,
type ToolCallRecord,
} from "@cline/shared";
import { filterDisabledTools } from "../../services/global-settings";
import {
createAgentModelFromConfig,
resolveKnownModelsFromConfig,
@@ -101,6 +102,36 @@ function mergeSystemPromptRules(
return base || additional;
}
function isToolEnabledByPolicies(
toolName: string,
toolPolicies: AgentConfig["toolPolicies"],
): boolean {
const globalPolicy = toolPolicies?.["*"] ?? {};
const toolPolicy = toolPolicies?.[toolName] ?? {};
return (
{
...globalPolicy,
...toolPolicy,
}.enabled !== false
);
}
function filterToolsByPolicies(
tools: AgentTool[],
toolPolicies: AgentConfig["toolPolicies"],
): AgentTool[] {
return tools.filter((tool) =>
isToolEnabledByPolicies(tool.name, toolPolicies),
);
}
function filterAvailableExtensionTools(
tools: AgentTool[],
toolPolicies: AgentConfig["toolPolicies"],
): AgentTool[] {
return filterDisabledTools(filterToolsByPolicies(tools, toolPolicies));
}
function mergeRuntimeHooks(
layers: Array<Partial<AgentRuntimeHooks> | undefined>,
): Partial<AgentRuntimeHooks> {
@@ -720,7 +751,14 @@ export class SessionRuntime {
// wins over a same-named extension tool (legacy behaviour:
// `validateTools` rejects duplicates; here we prefer the
// explicitly-declared config tool).
const extensionTools = this.contributionRegistry.getRegisteredTools();
const extensionToolsByName = new Map<string, AgentTool>();
for (const tool of this.contributionRegistry.getRegisteredTools()) {
extensionToolsByName.set(tool.name, tool);
}
const extensionTools = filterAvailableExtensionTools(
[...extensionToolsByName.values()],
this.config.toolPolicies,
);
const mergedToolsByName = new Map<string, AgentTool>();
for (const tool of extensionTools) {
mergedToolsByName.set(tool.name, tool);
@@ -850,7 +888,9 @@ export class SessionRuntime {
return;
}
try {
await this.contributionRegistry.initialize();
await this.contributionRegistry.initialize({
tolerateSetupErrors: this.config.hookErrorMode !== "throw",
});
} catch (error) {
if (this.config.hookErrorMode === "throw") {
throw error;
@@ -178,6 +178,7 @@ describe("prepareLocalRuntimeBootstrap", () => {
registerRule: () => {},
registerProvider: () => {},
registerAutomationEventType: () => {},
registerMcpServer: () => {},
},
{},
);
@@ -269,6 +270,7 @@ describe("prepareLocalRuntimeBootstrap", () => {
registerRule: () => {},
registerProvider: () => {},
registerAutomationEventType: () => {},
registerMcpServer: () => {},
},
{},
);
@@ -0,0 +1,337 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
disablePluginMcpServersInSettings,
removePluginMcpServersFromSettings,
syncPluginMcpServersToSettings,
} from "./plugin-mcp-settings";
describe("plugin MCP settings sync", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
),
);
tempRoots.length = 0;
});
async function createPlugin(source: string): Promise<{
root: string;
pluginPath: string;
settingsPath: string;
}> {
const root = await mkdtemp(join(tmpdir(), "core-plugin-mcp-settings-"));
tempRoots.push(root);
const pluginPath = join(root, "plugin.mjs");
const settingsPath = join(root, "cline_mcp_settings.json");
await writeFile(pluginPath, source, "utf8");
return { root, pluginPath, settingsPath };
}
it("writes plugin MCP servers into mcp settings", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "repo-docs",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "repo-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`);
const result = await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
expect(result.mutations).toEqual([
expect.objectContaining({
name: "repo-docs",
pluginName: "repo-docs",
pluginPath,
action: "created",
}),
]);
const written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ metadata?: Record<string, unknown>; transport?: unknown }
>;
};
expect(written.mcpServers?.["repo-docs"]?.metadata).toMatchObject({
source: "plugin",
pluginName: "repo-docs",
pluginPath,
});
});
it("does not create settings for plugins without MCP servers", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "plain-tools",
manifest: { capabilities: ["tools"] },
setup(api) {
api.registerTool({
name: "plain",
inputSchema: {},
execute: async () => "ok",
})
},
}
`);
const result = await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
expect(result).toEqual({
mutations: [],
failures: [],
});
await expect(readFile(settingsPath, "utf8")).rejects.toThrow();
});
it("removes stale owned servers when a plugin stops declaring MCP", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "plain-tools",
manifest: { capabilities: ["tools"] },
setup(api) {
api.registerTool({
name: "plain",
inputSchema: {},
execute: async () => "ok",
})
},
}
`);
await writeFile(
settingsPath,
JSON.stringify(
{
mcpServers: {
"old-docs": {
transport: {
type: "streamableHttp",
url: "https://old.example.com/mcp",
},
metadata: {
source: "plugin",
pluginName: "plain-tools",
pluginPath,
},
},
},
},
null,
2,
),
"utf8",
);
const result = await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
expect(result.mutations).toEqual([
expect.objectContaining({
name: "old-docs",
pluginName: "plain-tools",
pluginPath,
action: "removed",
}),
]);
const written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
};
expect(written.mcpServers?.["old-docs"]).toBeUndefined();
});
it("skips user-owned name collisions", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "repo-docs",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "repo-docs",
transport: { type: "streamableHttp", url: "https://plugin.example.com/mcp" },
})
},
}
`);
await writeFile(
settingsPath,
JSON.stringify(
{
mcpServers: {
"repo-docs": {
transport: {
type: "streamableHttp",
url: "https://user.example.com/mcp",
},
},
},
},
null,
2,
),
"utf8",
);
const result = await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
expect(result.mutations[0]).toMatchObject({
name: "repo-docs",
action: "skipped",
});
const written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { transport?: { url?: string } }>;
};
expect(written.mcpServers?.["repo-docs"]?.transport?.url).toBe(
"https://user.example.com/mcp",
);
});
it("does not overwrite invalid MCP settings", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "repo-docs",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "repo-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`);
await writeFile(settingsPath, "{ nope", "utf8");
const result = await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
expect(result.failures[0]).toEqual(
expect.objectContaining({
pluginPath,
}),
);
expect(await readFile(settingsPath, "utf8")).toBe("{ nope");
});
it("preserves oauth when updating plugin-owned entries", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "repo-docs",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "repo-docs",
transport: { type: "streamableHttp", url: "https://new.example.com/mcp" },
})
},
}
`);
await writeFile(
settingsPath,
JSON.stringify(
{
mcpServers: {
"repo-docs": {
transport: {
type: "streamableHttp",
url: "https://old.example.com/mcp",
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "repo-docs",
pluginPath,
},
},
},
},
null,
2,
),
"utf8",
);
await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
const written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{
transport?: { url?: string };
oauth?: { tokens?: Record<string, string> };
}
>;
};
expect(written.mcpServers?.["repo-docs"]?.transport?.url).toBe(
"https://new.example.com/mcp",
);
expect(written.mcpServers?.["repo-docs"]?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it("disables and removes plugin-owned entries", async () => {
const { pluginPath, settingsPath } = await createPlugin(`
export default {
name: "repo-docs",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "repo-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`);
await syncPluginMcpServersToSettings({
pluginPaths: [pluginPath],
settingsPath,
});
disablePluginMcpServersInSettings({
pluginPaths: [pluginPath],
settingsPath,
});
let written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean } | undefined>;
};
expect(written.mcpServers?.["repo-docs"]?.disabled).toBe(true);
removePluginMcpServersFromSettings({
pluginPaths: [pluginPath],
settingsPath,
});
written = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean } | undefined>;
};
expect(written.mcpServers?.["repo-docs"]).toBeUndefined();
});
});
@@ -0,0 +1,502 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import type {
AgentConfig,
AgentExtensionMcpServer,
AgentTool,
} from "@cline/shared";
import {
type McpServerRegistration,
resolveDefaultMcpSettingsPath,
resolvePluginMcpServerRegistrations,
} from "../extensions/mcp";
import { loadSandboxedPlugins } from "../extensions/plugin/plugin-sandbox";
type AgentExtension = NonNullable<AgentConfig["extensions"]>[number];
type AgentExtensionApi = Parameters<NonNullable<AgentExtension["setup"]>>[0];
type AgentExtensionWithPath = AgentExtension & { __clinePluginPath?: string };
export interface PluginMcpSettingsMutation {
name: string;
pluginName: string;
pluginPath: string;
action: "created" | "updated" | "skipped" | "removed" | "disabled";
reason?: string;
}
export interface PluginMcpSettingsSyncResult {
mutations: PluginMcpSettingsMutation[];
failures: Array<{
pluginPath: string;
pluginName?: string;
message: string;
}>;
}
export interface SyncPluginMcpServersToSettingsOptions {
pluginPaths: ReadonlyArray<string>;
cwd?: string;
workspacePath?: string;
settingsPath?: string;
providerId?: string;
modelId?: string;
}
export interface RemovePluginMcpServersFromSettingsOptions {
pluginPaths?: ReadonlyArray<string>;
pluginNames?: ReadonlyArray<string>;
settingsPath?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function readRawSettings(filePath: string): Record<string, unknown> {
if (!existsSync(filePath)) {
return { mcpServers: {} };
}
try {
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
if (isRecord(parsed)) {
return parsed;
}
} catch (error) {
throw new Error(
`Invalid MCP settings at "${filePath}": ${
error instanceof Error ? error.message : String(error)
}`,
);
}
throw new Error(`Invalid MCP settings at "${filePath}": expected an object`);
}
function getServers(
settings: Record<string, unknown>,
): Record<string, unknown> {
if (isRecord(settings.mcpServers)) {
return { ...settings.mcpServers };
}
return {};
}
function writeRawSettings(
filePath: string,
settings: Record<string, unknown>,
servers: Record<string, unknown>,
): void {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(
filePath,
`${JSON.stringify({ ...settings, mcpServers: servers }, null, 2)}\n`,
"utf8",
);
}
function collectOwnerInputs(input: RemovePluginMcpServersFromSettingsOptions): {
pluginPaths: string[];
pluginNames: string[];
} {
return {
pluginPaths: [
...new Set(
(input.pluginPaths ?? [])
.map((path) => path.trim())
.filter((path) => path.length > 0),
),
],
pluginNames: [
...new Set(
(input.pluginNames ?? [])
.map((name) => name.trim())
.filter((name) => name.length > 0),
),
],
};
}
function getMetadata(record: unknown): Record<string, unknown> | undefined {
if (!isRecord(record)) {
return undefined;
}
const metadata = record.metadata;
return isRecord(metadata) ? metadata : undefined;
}
function isPluginOwnedRecord(
record: unknown,
input: {
pluginName?: string;
pluginPath?: string;
pluginPaths?: ReadonlyArray<string>;
pluginNames?: ReadonlyArray<string>;
},
): boolean {
const metadata = getMetadata(record);
if (!metadata || metadata.source !== "plugin") {
return false;
}
const recordPluginName =
typeof metadata.pluginName === "string"
? metadata.pluginName
: typeof metadata.plugin === "string"
? metadata.plugin
: undefined;
const recordPluginPath =
typeof metadata.pluginPath === "string" ? metadata.pluginPath : undefined;
if (input.pluginName && recordPluginName === input.pluginName) {
if (!input.pluginPath || recordPluginPath === input.pluginPath) {
return true;
}
}
if (
recordPluginPath &&
input.pluginPath &&
(recordPluginPath === input.pluginPath ||
isPathWithin(input.pluginPath, recordPluginPath) ||
isPathWithin(recordPluginPath, input.pluginPath))
) {
return true;
}
if (
recordPluginName &&
input.pluginNames?.some((name) => name === recordPluginName)
) {
return true;
}
if (
recordPluginPath &&
input.pluginPaths?.some(
(path) =>
recordPluginPath === path ||
isPathWithin(path, recordPluginPath) ||
isPathWithin(recordPluginPath, path),
)
) {
return true;
}
return false;
}
function createSettingsEntry(input: {
registration: McpServerRegistration;
pluginName: string;
pluginPath: string;
existing?: Record<string, unknown>;
disabled?: boolean;
}): Record<string, unknown> {
const existingOauth = isRecord(input.existing?.oauth)
? { oauth: input.existing.oauth }
: {};
return {
transport: input.registration.transport,
...(input.disabled ? { disabled: true } : {}),
...existingOauth,
metadata: {
...(input.registration.metadata ?? {}),
source: "plugin",
pluginName: input.pluginName,
pluginPath: input.pluginPath,
},
};
}
async function collectPluginMcpServers(
options: SyncPluginMcpServersToSettingsOptions,
): Promise<{
plugins: Array<{
pluginName: string;
pluginPath: string;
}>;
servers: Array<{
pluginName: string;
pluginPath: string;
server: AgentExtensionMcpServer;
}>;
}> {
if (options.pluginPaths.length === 0) {
return { plugins: [], servers: [] };
}
const sandboxed = await loadSandboxedPlugins({
pluginPaths: [...options.pluginPaths],
cwd: options.cwd,
providerId: options.providerId,
modelId: options.modelId,
workspaceInfo: options.workspacePath
? { rootPath: options.workspacePath }
: undefined,
});
try {
const plugins: Array<{
pluginName: string;
pluginPath: string;
}> = [];
const servers: Array<{
pluginName: string;
pluginPath: string;
server: AgentExtensionMcpServer;
}> = [];
for (const extension of sandboxed.extensions ?? []) {
const pluginPath = (extension as AgentExtensionWithPath)
.__clinePluginPath;
if (!pluginPath || !extension.setup) {
continue;
}
plugins.push({
pluginName: extension.name,
pluginPath,
});
const mcpServers: AgentExtensionMcpServer[] = [];
const api: AgentExtensionApi = {
registerTool: (_tool: AgentTool) => {},
registerCommand: () => {},
registerMessageBuilder: () => {},
registerRule: () => {},
registerProvider: () => {},
registerAutomationEventType: () => {},
registerMcpServer: (server) => {
if (!extension.manifest.capabilities.includes("mcp")) {
throw new Error('registerMcpServer requires the "mcp" capability');
}
mcpServers.push(server);
},
};
await extension.setup(api, {
workspaceInfo: options.workspacePath
? { rootPath: options.workspacePath }
: undefined,
});
for (const server of mcpServers) {
servers.push({
pluginName: extension.name,
pluginPath,
server: {
...server,
metadata: {
...(server.metadata ?? {}),
source: "plugin",
pluginName: extension.name,
pluginPath,
},
},
});
}
}
return { plugins, servers };
} finally {
await sandboxed.shutdown().catch(() => {
// Best-effort cleanup after contribution discovery.
});
}
}
export async function syncPluginMcpServersToSettings(
options: SyncPluginMcpServersToSettingsOptions,
): Promise<PluginMcpSettingsSyncResult> {
const settingsPath = options.settingsPath ?? resolveDefaultMcpSettingsPath();
const result: PluginMcpSettingsSyncResult = {
mutations: [],
failures: [],
};
let collected: Awaited<ReturnType<typeof collectPluginMcpServers>>;
try {
collected = await collectPluginMcpServers(options);
} catch (error) {
for (const pluginPath of options.pluginPaths) {
result.failures.push({
pluginPath,
message: error instanceof Error ? error.message : String(error),
});
}
return result;
}
let settings: Record<string, unknown>;
try {
settings = readRawSettings(settingsPath);
} catch (error) {
for (const pluginPath of options.pluginPaths) {
result.failures.push({
pluginPath,
message: error instanceof Error ? error.message : String(error),
});
}
return result;
}
const servers = getServers(settings);
const declaredNamesByPluginPath = new Map<string, Set<string>>();
const pluginNameByPath = new Map<string, string>();
for (const plugin of collected.plugins) {
declaredNamesByPluginPath.set(plugin.pluginPath, new Set());
pluginNameByPath.set(plugin.pluginPath, plugin.pluginName);
}
const resolved = resolvePluginMcpServerRegistrations(
collected.servers.map((entry) => ({
server: entry.server,
owner: entry,
ownerLabel: entry.pluginName,
})),
);
for (const resolution of resolved) {
const owner = resolution.owner;
if (!declaredNamesByPluginPath.has(owner.pluginPath)) {
declaredNamesByPluginPath.set(owner.pluginPath, new Set());
}
if (resolution.name) {
declaredNamesByPluginPath.get(owner.pluginPath)?.add(resolution.name);
}
if (!resolution.registration) {
result.mutations.push({
name: resolution.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
action: "skipped",
reason: resolution.loadError ?? "invalid MCP server registration",
});
continue;
}
const existing = servers[resolution.registration.name];
if (existing !== undefined) {
if (
!isPluginOwnedRecord(existing, {
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
})
) {
result.mutations.push({
name: resolution.registration.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
action: "skipped",
reason: "MCP server name is already configured",
});
continue;
}
}
servers[resolution.registration.name] = createSettingsEntry({
registration: resolution.registration,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
existing: isRecord(existing) ? existing : undefined,
});
result.mutations.push({
name: resolution.registration.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
action: existing === undefined ? "created" : "updated",
});
}
for (const [serverName, record] of Object.entries(servers)) {
const metadata = getMetadata(record);
const pluginPath =
typeof metadata?.pluginPath === "string" ? metadata.pluginPath : "";
const declaredNames = declaredNamesByPluginPath.get(pluginPath);
if (
declaredNames &&
isPluginOwnedRecord(record, { pluginPath }) &&
!declaredNames.has(serverName)
) {
delete servers[serverName];
result.mutations.push({
name: serverName,
pluginName:
typeof metadata?.pluginName === "string"
? metadata.pluginName
: (pluginNameByPath.get(pluginPath) ?? "plugin"),
pluginPath,
action: "removed",
reason: "plugin no longer declares this MCP server",
});
}
}
if (result.mutations.some((mutation) => mutation.action !== "skipped")) {
writeRawSettings(settingsPath, settings, servers);
}
return result;
}
export function disablePluginMcpServersInSettings(
options: RemovePluginMcpServersFromSettingsOptions,
): PluginMcpSettingsMutation[] {
const settingsPath = options.settingsPath ?? resolveDefaultMcpSettingsPath();
let settings: Record<string, unknown>;
try {
settings = readRawSettings(settingsPath);
} catch {
return [];
}
const servers = getServers(settings);
const ownerInput = collectOwnerInputs(options);
const mutations: PluginMcpSettingsMutation[] = [];
for (const [serverName, record] of Object.entries(servers)) {
if (!isRecord(record) || !isPluginOwnedRecord(record, ownerInput)) {
continue;
}
const metadata = getMetadata(record);
servers[serverName] = { ...record, disabled: true };
mutations.push({
name: serverName,
pluginName:
typeof metadata?.pluginName === "string"
? metadata.pluginName
: "plugin",
pluginPath:
typeof metadata?.pluginPath === "string" ? metadata.pluginPath : "",
action: "disabled",
});
}
if (mutations.length > 0) {
writeRawSettings(settingsPath, settings, servers);
}
return mutations;
}
export function removePluginMcpServersFromSettings(
options: RemovePluginMcpServersFromSettingsOptions,
): PluginMcpSettingsMutation[] {
const settingsPath = options.settingsPath ?? resolveDefaultMcpSettingsPath();
let settings: Record<string, unknown>;
try {
settings = readRawSettings(settingsPath);
} catch {
return [];
}
const servers = getServers(settings);
const ownerInput = collectOwnerInputs(options);
const mutations: PluginMcpSettingsMutation[] = [];
for (const [serverName, record] of Object.entries(servers)) {
if (!isPluginOwnedRecord(record, ownerInput)) {
continue;
}
const metadata = getMetadata(record);
delete servers[serverName];
mutations.push({
name: serverName,
pluginName:
typeof metadata?.pluginName === "string"
? metadata.pluginName
: "plugin",
pluginPath:
typeof metadata?.pluginPath === "string" ? metadata.pluginPath : "",
action: "removed",
});
}
if (mutations.length > 0) {
writeRawSettings(settingsPath, settings, servers);
}
return mutations;
}
+55 -88
View File
@@ -1,4 +1,4 @@
import { stat } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import type { AgentConfig, AgentTool } from "@cline/shared";
import { resolveAgentPluginPaths } from "../extensions/plugin/plugin-config-loader";
import type {
@@ -12,6 +12,14 @@ type AgentExtension = NonNullable<AgentConfig["extensions"]>[number];
type AgentExtensionApi = Parameters<NonNullable<AgentExtension["setup"]>>[0];
type AgentExtensionWithPath = AgentExtension & { __clinePluginPath?: string };
function isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
export interface PluginToolSummary {
name: string;
pluginName: string;
@@ -19,68 +27,27 @@ export interface PluginToolSummary {
source: "workspace-plugin" | "global-plugin";
enabled: boolean;
description?: string;
mcpServerName?: string;
}
export interface PluginMcpServerSummary {
name: string;
pluginName: string;
path: string;
source: "workspace-plugin" | "global-plugin";
enabled: boolean;
description?: string;
loadError?: string;
}
export interface ListPluginToolsResult {
tools: PluginToolSummary[];
mcpServers: PluginMcpServerSummary[];
failures: PluginInitializationFailure[];
warnings: PluginInitializationWarning[];
}
type PluginToolDescriptor = Omit<PluginToolSummary, "enabled">;
type PluginToolDescriptorCacheEntry = {
tools: PluginToolDescriptor[];
failures: PluginInitializationFailure[];
warnings: PluginInitializationWarning[];
};
const MAX_PLUGIN_TOOL_DESCRIPTOR_CACHE_ENTRIES = 32;
const pluginToolDescriptorCache = new Map<
string,
PluginToolDescriptorCacheEntry
>();
function cachePluginToolDescriptors(
key: string,
entry: PluginToolDescriptorCacheEntry,
): void {
if (
!pluginToolDescriptorCache.has(key) &&
pluginToolDescriptorCache.size >= MAX_PLUGIN_TOOL_DESCRIPTOR_CACHE_ENTRIES
) {
const oldestKey = pluginToolDescriptorCache.keys().next().value;
if (oldestKey) {
pluginToolDescriptorCache.delete(oldestKey);
}
}
pluginToolDescriptorCache.set(key, entry);
}
async function buildPluginToolDescriptorCacheKey(input: {
pluginPaths: ReadonlyArray<string>;
workspacePath: string;
cwd?: string;
providerId?: string;
modelId?: string;
}): Promise<string> {
const pathStats = await Promise.all(
input.pluginPaths.map(async (pluginPath) => {
try {
const stats = await stat(pluginPath);
return `${pluginPath}:${stats.mtimeMs}:${stats.size}`;
} catch {
return `${pluginPath}:missing`;
}
}),
);
return JSON.stringify({
workspacePath: input.workspacePath,
cwd: input.cwd,
providerId: input.providerId,
modelId: input.modelId,
pathStats,
});
}
function withEnabledState(
tools: readonly PluginToolDescriptor[],
@@ -104,12 +71,14 @@ function sortPluginToolDescriptors(
});
}
function collectRegisteredTools(
async function collectPluginContributions(
extension: AgentExtension,
workspaceInfo?: { rootPath: string },
): AgentTool[] {
): Promise<{
tools: AgentTool[];
}> {
if (!extension.setup) {
return [];
return { tools: [] };
}
const tools: AgentTool[] = [];
@@ -120,9 +89,14 @@ function collectRegisteredTools(
registerRule: () => {},
registerProvider: () => {},
registerAutomationEventType: () => {},
registerMcpServer: (_server) => {
if (!extension.manifest.capabilities.includes("mcp")) {
throw new Error('registerMcpServer requires the "mcp" capability');
}
},
};
extension.setup(api, { workspaceInfo });
return tools;
await extension.setup(api, { workspaceInfo });
return { tools };
}
export async function listPluginToolsWithDiagnostics(input: {
@@ -138,23 +112,7 @@ export async function listPluginToolsWithDiagnostics(input: {
});
const disabled = resolveDisabledToolNames(input.disabledToolNames);
if (pluginPaths.length === 0) {
return { tools: [], failures: [], warnings: [] };
}
const cacheKey = await buildPluginToolDescriptorCacheKey({
pluginPaths,
workspacePath: input.workspacePath,
cwd: input.cwd,
providerId: input.providerId,
modelId: input.modelId,
});
const cached = pluginToolDescriptorCache.get(cacheKey);
if (cached) {
return {
tools: withEnabledState(cached.tools, disabled),
failures: cached.failures,
warnings: cached.warnings,
};
return { tools: [], mcpServers: [], failures: [], warnings: [] };
}
const tools: PluginToolDescriptor[] = [];
@@ -178,16 +136,30 @@ export async function listPluginToolsWithDiagnostics(input: {
if (!pluginPath) {
continue;
}
for (const tool of collectRegisteredTools(extension, {
rootPath: input.workspacePath,
})) {
const pluginSource = isPathWithin(input.workspacePath, pluginPath)
? "workspace-plugin"
: "global-plugin";
let contributions: Awaited<ReturnType<typeof collectPluginContributions>>;
try {
contributions = await collectPluginContributions(extension, {
rootPath: input.workspacePath,
});
} catch (error) {
failures.push({
pluginPath,
pluginName: extension.name,
phase: "setup",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
continue;
}
for (const tool of contributions.tools) {
tools.push({
name: tool.name,
pluginName: extension.name,
path: pluginPath,
source: pluginPath.startsWith(input.workspacePath)
? "workspace-plugin"
: "global-plugin",
source: pluginSource,
description: tool.description?.trim() || undefined,
});
}
@@ -206,14 +178,9 @@ export async function listPluginToolsWithDiagnostics(input: {
}
const sortedTools = sortPluginToolDescriptors(tools);
const cacheEntry = {
tools: sortedTools,
failures,
warnings,
};
cachePluginToolDescriptors(cacheKey, cacheEntry);
return {
tools: withEnabledState(sortedTools, disabled),
mcpServers: [],
failures,
warnings,
};
@@ -21,6 +21,7 @@ import {
resolvePluginConfigSearchPaths,
} from "@cline/shared/storage";
import { readGlobalSettings, writeGlobalSettings } from "./global-settings";
import { removePluginMcpServersFromSettings } from "./plugin-mcp-settings";
export interface PluginUninstallOptions {
name?: string;
@@ -412,6 +413,10 @@ export async function uninstallPlugin(
force: true,
});
cleanupDisabledPluginPaths(candidate);
removePluginMcpServersFromSettings({
pluginPaths: [candidate.installPath, ...candidate.entryPaths],
pluginNames: candidate.names,
});
if (candidate.installed) {
cleanupEmptyInstallParents(candidate.installPath);
}
@@ -20,6 +20,7 @@ describe("ContributionRegistry automation event contributions", () => {
messageBuilder: [],
providers: [],
automationEventTypes: [],
mcpServers: [],
});
});
@@ -153,4 +154,37 @@ describe("ContributionRegistry automation event contributions", () => {
/registerAutomationEventType requires the "automationEvents" capability/,
);
});
it("registers MCP servers declared by plugins", async () => {
const registry = createContributionRegistry({
extensions: [
{
name: "github-pack",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "github",
transport: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
},
});
},
},
],
});
await registry.initialize();
expect(registry.getRegisteredMcpServers()).toEqual([
expect.objectContaining({
name: "github",
metadata: {
source: "plugin",
plugin: "github-pack",
},
}),
]);
});
});
@@ -49,6 +49,53 @@ export interface AgentExtensionAutomationEventType {
metadata?: Record<string, unknown>;
}
export interface AgentExtensionMcpEnvValue {
// Read this environment variable from the host process. Defaults to the target env var name.
fromEnv?: string;
// Literal fallback value used when `fromEnv` is omitted or not set.
value?: string;
// Skip the MCP server when no value can be resolved.
required?: boolean;
}
export type AgentExtensionMcpEnv = Record<
string,
string | AgentExtensionMcpEnvValue
>;
export interface AgentExtensionMcpStdioTransport {
type: "stdio";
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}
export interface AgentExtensionMcpSseTransport {
type: "sse";
url: string;
headers?: Record<string, string>;
}
export interface AgentExtensionMcpStreamableHttpTransport {
type: "streamableHttp";
url: string;
headers?: Record<string, string>;
}
export type AgentExtensionMcpTransport =
| AgentExtensionMcpStdioTransport
| AgentExtensionMcpSseTransport
| AgentExtensionMcpStreamableHttpTransport;
export interface AgentExtensionMcpServer {
name: string;
transport: AgentExtensionMcpTransport;
// Top-level env values are merged into stdio process env only.
env?: AgentExtensionMcpEnv;
metadata?: Record<string, unknown>;
}
export interface AgentExtensionAutomationContext {
/**
* Submit a normalized automation event to the host. Raw webhook or connector
@@ -87,6 +134,8 @@ export interface AgentExtensionApi<TTool = AgentTool, TMessage = unknown> {
registerAutomationEventType: (
eventType: AgentExtensionAutomationEventType,
) => void;
// Register an MCP server exposed as runtime tools. Requires the `mcp` capability.
registerMcpServer: (server: AgentExtensionMcpServer) => void;
}
export type AgentExtensionHooks = Partial<AgentRuntimeHooks>;
@@ -148,6 +197,7 @@ const ExtensionCapabilityOptions = [
"messageBuilders",
"providers",
"automationEvents",
"mcp",
] as const;
export type AgentExtensionCapability =
@@ -167,6 +217,7 @@ export interface AgentExtensionRegistry<TTool = AgentTool, TMessage = unknown> {
messageBuilder: AgentExtensionMessageBuilder<TMessage>[];
providers: AgentExtensionProvider[];
automationEventTypes: AgentExtensionAutomationEventType[];
mcpServers: AgentExtensionMcpServer[];
}
/**
@@ -221,6 +272,10 @@ export interface ContributionRegistryOptions<
setupContext?: PluginSetupContext;
}
export interface ContributionRegistryInitializeOptions {
tolerateSetupErrors?: boolean;
}
interface NormalizedExtension<
TExtension extends ContributionRegistryExtension<TTool, TMessage>,
TTool,
@@ -382,6 +437,7 @@ export class ContributionRegistry<
messageBuilder: [],
providers: [],
automationEventTypes: [],
mcpServers: [],
};
private normalized: NormalizedExtension<TExtension, TTool, TMessage>[] = [];
private phase: "resolve" | "validate" | "setup" | "activate" | "run" =
@@ -421,39 +477,67 @@ export class ContributionRegistry<
this.phase = "setup";
}
async setup(): Promise<void> {
async setup(
options: ContributionRegistryInitializeOptions = {},
): Promise<void> {
if (this.phase === "resolve") this.resolve();
if (this.phase === "validate") this.validate();
if (this.phase !== "setup") return;
let firstSetupError: unknown;
const successfulSetups: AgentExtensionRegistry<TTool, TMessage>[] = [];
for (const entry of this.normalized) {
const { extension } = entry;
if (extension.disabled) continue;
const extensionName = asExtensionName(extension, entry.order);
const pending: AgentExtensionRegistry<TTool, TMessage> = {
tools: [],
commands: [],
rules: [],
messageBuilder: [],
providers: [],
automationEventTypes: [],
mcpServers: [],
};
const api: AgentExtensionApi<TTool, TMessage> = {
registerTool: (tool) => this.registry.tools.push(tool),
registerCommand: (command) => this.registry.commands.push(command),
registerTool: (tool) => pending.tools.push(tool),
registerCommand: (command) => pending.commands.push(command),
registerRule: (rule) => {
if (!entry.manifest.capabilities.has("rules")) {
throw new Error(
`Invalid setup for extension "${extensionName}": registerRule requires the "rules" capability`,
);
}
this.registry.rules.push(rule);
pending.rules.push(rule);
},
registerMessageBuilder: (builder) =>
this.registry.messageBuilder.push(builder),
registerProvider: (provider) => this.registry.providers.push(provider),
pending.messageBuilder.push(builder),
registerProvider: (provider) => pending.providers.push(provider),
registerAutomationEventType: (eventType) => {
if (!entry.manifest.capabilities.has("automationEvents")) {
throw new Error(
`Invalid setup for extension "${extensionName}": registerAutomationEventType requires the "automationEvents" capability`,
);
}
this.registry.automationEventTypes.push(
pending.automationEventTypes.push(
normalizeAutomationEventType(eventType, extensionName),
);
},
registerMcpServer: (server) => {
if (!entry.manifest.capabilities.has("mcp")) {
throw new Error(
`Invalid setup for extension "${extensionName}": registerMcpServer requires the "mcp" capability`,
);
}
pending.mcpServers.push({
...server,
metadata: {
...(server.metadata ?? {}),
source: "plugin",
plugin: extensionName,
},
});
},
};
const setupContext = entry.manifest.capabilities.has("automationEvents")
? this.setupContext
@@ -461,9 +545,32 @@ export class ContributionRegistry<
...this.setupContext,
automation: undefined,
};
await extension.setup?.(api, setupContext);
try {
await extension.setup?.(api, setupContext);
successfulSetups.push(pending);
} catch (error) {
if (options.tolerateSetupErrors !== true) {
throw error;
}
firstSetupError ??= error;
}
}
if (firstSetupError && options.tolerateSetupErrors !== true) {
throw firstSetupError;
}
for (const pending of successfulSetups) {
this.registry.tools.push(...pending.tools);
this.registry.commands.push(...pending.commands);
this.registry.rules.push(...pending.rules);
this.registry.messageBuilder.push(...pending.messageBuilder);
this.registry.providers.push(...pending.providers);
this.registry.automationEventTypes.push(...pending.automationEventTypes);
this.registry.mcpServers.push(...pending.mcpServers);
}
this.phase = "activate";
if (firstSetupError) {
throw firstSetupError;
}
}
activate(): void {
@@ -478,11 +585,18 @@ export class ContributionRegistry<
this.phase = "run";
}
async initialize(): Promise<void> {
async initialize(
options: ContributionRegistryInitializeOptions = {},
): Promise<void> {
this.resolve();
this.validate();
await this.setup();
this.activate();
try {
await this.setup(options);
} finally {
if (this.phase === "activate") {
this.activate();
}
}
}
isActivated(): boolean {
@@ -497,6 +611,7 @@ export class ContributionRegistry<
messageBuilder: [...this.registry.messageBuilder],
providers: [...this.registry.providers],
automationEventTypes: [...this.registry.automationEventTypes],
mcpServers: [...this.registry.mcpServers],
};
}
@@ -512,6 +627,10 @@ export class ContributionRegistry<
return [...this.registry.automationEventTypes];
}
getRegisteredMcpServers(): AgentExtensionMcpServer[] {
return [...this.registry.mcpServers];
}
getValidatedExtensions(): TExtension[] {
if (this.phase === "resolve") this.resolve();
if (this.phase === "validate") this.validate();
+7
View File
@@ -33,6 +33,13 @@ export type {
AgentExtensionCommand,
AgentExtensionCommandResult,
AgentExtensionHooks,
AgentExtensionMcpEnv,
AgentExtensionMcpEnvValue,
AgentExtensionMcpServer,
AgentExtensionMcpSseTransport,
AgentExtensionMcpStdioTransport,
AgentExtensionMcpStreamableHttpTransport,
AgentExtensionMcpTransport,
AgentExtensionMessageBuilder,
AgentExtensionProvider,
AgentExtensionRegistry,
+7
View File
@@ -47,6 +47,13 @@ export type {
AgentExtensionCommand,
AgentExtensionCommandResult,
AgentExtensionHooks,
AgentExtensionMcpEnv,
AgentExtensionMcpEnvValue,
AgentExtensionMcpServer,
AgentExtensionMcpSseTransport,
AgentExtensionMcpStdioTransport,
AgentExtensionMcpStreamableHttpTransport,
AgentExtensionMcpTransport,
AgentExtensionMessageBuilder,
AgentExtensionProvider,
AgentExtensionRegistry,