mirror of
https://github.com/cline/cline.git
synced 2026-09-07 04:44:58 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae65f2230c | |||
| 249dfde4bf | |||
| ed2478a0d6 | |||
| e240132ff5 | |||
| d3fc7155fa |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -33,6 +33,13 @@ export type {
|
||||
AgentExtensionCommand,
|
||||
AgentExtensionCommandResult,
|
||||
AgentExtensionHooks,
|
||||
AgentExtensionMcpEnv,
|
||||
AgentExtensionMcpEnvValue,
|
||||
AgentExtensionMcpServer,
|
||||
AgentExtensionMcpSseTransport,
|
||||
AgentExtensionMcpStdioTransport,
|
||||
AgentExtensionMcpStreamableHttpTransport,
|
||||
AgentExtensionMcpTransport,
|
||||
AgentExtensionMessageBuilder,
|
||||
AgentExtensionProvider,
|
||||
AgentExtensionRegistry,
|
||||
|
||||
@@ -47,6 +47,13 @@ export type {
|
||||
AgentExtensionCommand,
|
||||
AgentExtensionCommandResult,
|
||||
AgentExtensionHooks,
|
||||
AgentExtensionMcpEnv,
|
||||
AgentExtensionMcpEnvValue,
|
||||
AgentExtensionMcpServer,
|
||||
AgentExtensionMcpSseTransport,
|
||||
AgentExtensionMcpStdioTransport,
|
||||
AgentExtensionMcpStreamableHttpTransport,
|
||||
AgentExtensionMcpTransport,
|
||||
AgentExtensionMessageBuilder,
|
||||
AgentExtensionProvider,
|
||||
AgentExtensionRegistry,
|
||||
|
||||
Reference in New Issue
Block a user