包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import type { McpExtensionState } from "./state.ts";
|
|
import type { McpAuthResult, McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult, ImportKind } from "./types.ts";
|
|
import {
|
|
ensureCompatibilityImports,
|
|
getMcpDiscoverySummary,
|
|
getServerProvenance,
|
|
previewCompatibilityImports,
|
|
previewSharedServerEntry,
|
|
previewStarterProjectConfig,
|
|
writeDirectToolsConfig,
|
|
writeSharedServerEntry,
|
|
writeStarterProjectConfig,
|
|
} from "./config.ts";
|
|
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.ts";
|
|
import { loadMetadataCache } from "./metadata-cache.ts";
|
|
import { buildToolMetadata } from "./tool-metadata.ts";
|
|
import { supportsOAuth, authenticate, removeAuth } from "./mcp-auth-flow.ts";
|
|
import { getAuthForUrl } from "./mcp-auth.ts";
|
|
import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
|
|
import { openPath } from "./utils.ts";
|
|
|
|
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
if (!ctx.hasUI) return;
|
|
|
|
const lines: string[] = ["MCP Server Status:", ""];
|
|
|
|
for (const name of Object.keys(state.config.mcpServers)) {
|
|
const connection = state.manager.getConnection(name);
|
|
const metadata = state.toolMetadata.get(name);
|
|
const toolCount = metadata?.length ?? 0;
|
|
const failedAgo = getFailureAgeSeconds(state, name);
|
|
let status = "not connected";
|
|
let statusIcon = "○";
|
|
let failed = false;
|
|
|
|
if (connection?.status === "connected") {
|
|
status = "connected";
|
|
statusIcon = "✓";
|
|
} else if (connection?.status === "needs-auth") {
|
|
status = "needs auth";
|
|
statusIcon = "⚠";
|
|
} else if (failedAgo !== null) {
|
|
status = `failed ${failedAgo}s ago`;
|
|
statusIcon = "✗";
|
|
failed = true;
|
|
} else if (metadata !== undefined) {
|
|
status = "cached";
|
|
}
|
|
|
|
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
|
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
|
}
|
|
|
|
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
lines.push("No MCP servers configured");
|
|
lines.push("Run /mcp setup to adopt imports or scaffold a starter .mcp.json");
|
|
}
|
|
|
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
}
|
|
|
|
export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
if (!ctx.hasUI) return;
|
|
|
|
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
|
|
|
if (allTools.length === 0) {
|
|
ctx.ui.notify("No MCP tools available", "info");
|
|
return;
|
|
}
|
|
|
|
const lines = [
|
|
"MCP Tools:",
|
|
"",
|
|
...allTools.map(t => ` ${t}`),
|
|
"",
|
|
`Total: ${allTools.length} tools`,
|
|
];
|
|
|
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
}
|
|
|
|
export async function reconnectServers(
|
|
state: McpExtensionState,
|
|
ctx: ExtensionContext,
|
|
targetServer?: string
|
|
): Promise<void> {
|
|
if (targetServer && !state.config.mcpServers[targetServer]) {
|
|
if (ctx.hasUI) {
|
|
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const entries = targetServer
|
|
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
|
: Object.entries(state.config.mcpServers);
|
|
|
|
for (const [name, definition] of entries) {
|
|
try {
|
|
await state.manager.close(name);
|
|
|
|
const connection = await state.manager.connect(name, definition);
|
|
if (connection.status === "needs-auth") {
|
|
if (ctx.hasUI) {
|
|
ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
|
|
}
|
|
continue;
|
|
}
|
|
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
|
|
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
state.toolMetadata.set(name, metadata);
|
|
updateMetadataCache(state, name);
|
|
state.failureTracker.delete(name);
|
|
|
|
if (ctx.hasUI) {
|
|
ctx.ui.notify(
|
|
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
"info"
|
|
);
|
|
if (failedTools.length > 0) {
|
|
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
state.failureTracker.set(name, Date.now());
|
|
if (ctx.hasUI) {
|
|
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
updateStatusBar(state);
|
|
}
|
|
|
|
export async function authenticateServer(
|
|
serverName: string,
|
|
config: McpConfig,
|
|
ctx: ExtensionContext
|
|
): Promise<McpAuthResult> {
|
|
if (!ctx.hasUI) return { ok: false, message: "OAuth authentication requires an interactive session." };
|
|
|
|
const definition = config.mcpServers[serverName];
|
|
if (!definition) {
|
|
const message = `Server "${serverName}" not found in config`;
|
|
ctx.ui.notify(message, "error");
|
|
return { ok: false, message };
|
|
}
|
|
|
|
if (!supportsOAuth(definition)) {
|
|
const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
|
|
ctx.ui.notify(
|
|
`Server "${serverName}" does not use OAuth authentication.\n` +
|
|
`Set "auth": "oauth" or omit auth for auto-detection.`,
|
|
"error"
|
|
);
|
|
return { ok: false, message };
|
|
}
|
|
|
|
if (!definition.url) {
|
|
const message = `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`;
|
|
ctx.ui.notify(message, "error");
|
|
return { ok: false, message };
|
|
}
|
|
|
|
try {
|
|
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
|
const status = await authenticate(serverName, definition.url, definition);
|
|
|
|
if (status === "authenticated") {
|
|
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
|
|
ctx.ui.notify(
|
|
`OAuth authentication successful for "${serverName}"!\n` +
|
|
`Run /mcp reconnect ${serverName} to connect with the new token.`,
|
|
"info"
|
|
);
|
|
return { ok: true, message };
|
|
}
|
|
|
|
const message = `OAuth authentication failed for "${serverName}".`;
|
|
ctx.ui.notify(message, "error");
|
|
return { ok: false, message };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
|
|
return { ok: false, message };
|
|
} finally {
|
|
ctx.ui.setStatus("mcp-auth", undefined);
|
|
}
|
|
}
|
|
|
|
export async function logoutServer(
|
|
serverName: string,
|
|
state: McpExtensionState,
|
|
ctx: ExtensionContext
|
|
): Promise<{ ok: boolean; message: string }> {
|
|
const definition = state.config.mcpServers[serverName];
|
|
if (!definition) {
|
|
const message = `Server "${serverName}" not found in config`;
|
|
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
return { ok: false, message };
|
|
}
|
|
|
|
await removeAuth(serverName);
|
|
await state.manager.close(serverName);
|
|
updateStatusBar(state);
|
|
|
|
const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
|
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
return { ok: true, message };
|
|
}
|
|
|
|
export interface PanelFlowResult {
|
|
configChanged: boolean;
|
|
}
|
|
|
|
function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cwd: string): { lines: string[]; fingerprint: string | null } {
|
|
const discovery = getMcpDiscoverySummary(configOverridePath, cwd);
|
|
const onboardingState = loadOnboardingState();
|
|
if (!discovery.hasSharedServers || onboardingState.sharedConfigHintShown) {
|
|
return { lines: [], fingerprint: null };
|
|
}
|
|
|
|
const sharedSources = discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0);
|
|
const sourceList = sharedSources.map((source) => source.path).join(", ");
|
|
return {
|
|
lines: [
|
|
`Using standard MCP config from ${sourceList}.`,
|
|
"Pi only writes compatibility imports and adapter-specific overrides into Pi-owned files when needed.",
|
|
],
|
|
fingerprint: discovery.fingerprint,
|
|
};
|
|
}
|
|
|
|
export async function openMcpSetup(
|
|
_state: McpExtensionState,
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
configOverridePath?: string,
|
|
mode: "empty" | "setup" = "setup",
|
|
): Promise<PanelFlowResult> {
|
|
if (!ctx.hasUI) return { configChanged: false };
|
|
|
|
const discovery = getMcpDiscoverySummary(configOverridePath, ctx.cwd);
|
|
const onboardingState = loadOnboardingState();
|
|
const { createMcpSetupPanel } = await import("./mcp-setup-panel.ts");
|
|
let configChanged = false;
|
|
|
|
const callbacks = {
|
|
previewImports: (imports: ImportKind[]) => previewCompatibilityImports(imports, configOverridePath),
|
|
previewStarterProject: () => previewStarterProjectConfig(ctx.cwd),
|
|
previewRepoPrompt: () => {
|
|
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
|
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) return null;
|
|
return previewSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
|
},
|
|
adoptImports: async (imports: ImportKind[]) => {
|
|
const result = ensureCompatibilityImports(imports, configOverridePath);
|
|
if (result.added.length > 0) configChanged = true;
|
|
return result;
|
|
},
|
|
scaffoldProjectConfig: async () => {
|
|
const path = writeStarterProjectConfig(ctx.cwd);
|
|
configChanged = true;
|
|
return { path };
|
|
},
|
|
addRepoPrompt: async () => {
|
|
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
|
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) {
|
|
throw new Error("RepoPrompt is not available to add from this setup screen.");
|
|
}
|
|
const path = writeSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
|
configChanged = true;
|
|
return { path, serverName: repoPrompt.serverName };
|
|
},
|
|
openPath: async (targetPath: string) => {
|
|
await openPath(pi, targetPath);
|
|
},
|
|
markSetupCompleted: () => {
|
|
persistSetupCompleted(discovery.fingerprint);
|
|
},
|
|
};
|
|
|
|
return new Promise<PanelFlowResult>((resolve) => {
|
|
ctx.ui.custom(
|
|
(tui, _theme, keybindings, done) => {
|
|
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
|
|
done(undefined);
|
|
resolve({ configChanged });
|
|
});
|
|
},
|
|
{ overlay: true, overlayOptions: { anchor: "center", width: 92 } },
|
|
);
|
|
});
|
|
}
|
|
|
|
function buildMcpPanelCallbacks(
|
|
state: McpExtensionState,
|
|
config: McpConfig,
|
|
ctx: ExtensionContext,
|
|
): McpPanelCallbacks {
|
|
return {
|
|
reconnect: (serverName: string) => lazyConnect(state, serverName),
|
|
canAuthenticate: (serverName: string) => {
|
|
const definition = config.mcpServers[serverName];
|
|
return definition ? supportsOAuth(definition) : false;
|
|
},
|
|
authenticate: (serverName: string) => authenticateServer(serverName, config, ctx),
|
|
getConnectionStatus: (serverName: string) => {
|
|
const definition = config.mcpServers[serverName];
|
|
const connection = state.manager.getConnection(serverName);
|
|
if (connection?.status === "needs-auth") {
|
|
return "needs-auth";
|
|
}
|
|
if (
|
|
definition?.auth === "oauth"
|
|
&& definition.url
|
|
&& definition.oauth !== false
|
|
&& definition.oauth?.grantType !== "client_credentials"
|
|
&& !getAuthForUrl(serverName, definition.url)?.tokens
|
|
) {
|
|
return "needs-auth";
|
|
}
|
|
if (connection?.status === "connected") return "connected";
|
|
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
return "idle";
|
|
},
|
|
refreshCacheAfterReconnect: (serverName: string) => {
|
|
const freshCache = loadMetadataCache();
|
|
return freshCache?.servers?.[serverName] ?? null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function openMcpPanel(
|
|
state: McpExtensionState,
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
configOverridePath?: string,
|
|
): Promise<PanelFlowResult> {
|
|
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
return openMcpSetup(state, pi, ctx, configOverridePath, "empty");
|
|
}
|
|
|
|
const config = state.config;
|
|
const cache = loadMetadataCache();
|
|
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
|
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
|
const { lines: noticeLines, fingerprint } = buildSharedConfigNoticeLines(configPath, ctx.cwd);
|
|
|
|
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
|
|
|
const { createMcpPanel } = await import("./mcp-panel.ts");
|
|
let configChanged = false;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ctx.ui.custom(
|
|
(tui, _theme, keybindings, done) => {
|
|
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
|
if (!result.cancelled && result.changes.size > 0) {
|
|
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
|
configChanged = true;
|
|
ctx.ui.notify("Direct tools updated. Pi will reload after this panel closes.", "info");
|
|
}
|
|
done(undefined);
|
|
resolve();
|
|
}, { noticeLines, keybindings });
|
|
},
|
|
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
);
|
|
});
|
|
|
|
if (noticeLines.length > 0 && fingerprint) {
|
|
markSharedConfigHintShown(fingerprint);
|
|
}
|
|
|
|
return { configChanged };
|
|
}
|
|
|
|
export async function openMcpAuthPanel(
|
|
state: McpExtensionState,
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
configOverridePath?: string,
|
|
): Promise<PanelFlowResult> {
|
|
if (!ctx.hasUI) return { configChanged: false };
|
|
|
|
const config = state.config;
|
|
const oauthServers = Object.entries(config.mcpServers).filter(([, definition]) => supportsOAuth(definition));
|
|
if (oauthServers.length === 0) {
|
|
ctx.ui.notify("No OAuth-capable MCP servers are configured.", "warning");
|
|
return { configChanged: false };
|
|
}
|
|
|
|
const cache = loadMetadataCache();
|
|
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
|
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
|
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
|
const { createMcpPanel } = await import("./mcp-panel.ts");
|
|
|
|
await new Promise<void>((resolve) => {
|
|
ctx.ui.custom(
|
|
(tui, _theme, keybindings, done) => {
|
|
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
|
|
done(undefined);
|
|
resolve();
|
|
}, {
|
|
authOnly: true,
|
|
keybindings,
|
|
noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
|
|
});
|
|
},
|
|
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
);
|
|
});
|
|
|
|
return { configChanged: false };
|
|
}
|