diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 25c809467..e2bbe4cb6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/reload-plugins` not reconnecting MCP servers or refreshing the session's MCP tool registry despite listing MCP in its documented reload scope, so `.mcp.json` edits stayed inactive until restart. The TUI reload pipeline now runs the same disconnect/rediscover/`refreshMCPTools` path as `/mcp reload` ([#7189](https://github.com/can1357/oh-my-pi/issues/7189)). + ## [17.2.1] - 2026-07-30 ### Added diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index f32e58133..ca57a3480 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -1228,7 +1228,7 @@ export class MCPCommandController { await addMCPServer(filePath, name, config); // Reload MCP manager - await this.#reloadMCP(); + await this.reloadServers(); const state = config.enabled === false ? "disconnected" @@ -1486,7 +1486,7 @@ export class MCPCommandController { await removeMCPServer(filePath, name); // Reload MCP manager - await this.#reloadMCP(); + await this.reloadServers(); this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n")); } catch (error) { @@ -1736,7 +1736,7 @@ export class MCPCommandController { ); return; } - await this.#reloadMCP(); + await this.reloadServers(); this.#showMessage( ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), ); @@ -1745,7 +1745,7 @@ export class MCPCommandController { const updated = this.#stripOAuthAuth(found.config); await updateMCPServer(found.filePath, name, updated); - await this.#reloadMCP(); + await this.reloadServers(); this.#showMessage( ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"), @@ -1856,7 +1856,7 @@ export class MCPCommandController { await updateMCPServer(found.filePath, name, updatedConfig); } if (options.reload !== false) { - await this.#reloadMCP(); + await this.reloadServers(); const state = await this.#waitForServerConnectionWithAnimation(name); const lines = [ @@ -1891,7 +1891,7 @@ export class MCPCommandController { async #handleReload(): Promise { try { this.#showMessage(["", theme.fg("muted", "Reloading MCP servers and runtime tools..."), ""].join("\n")); - await this.#reloadMCP(); + await this.reloadServers(); const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0; this.#showMessage( [ @@ -1979,9 +1979,14 @@ export class MCPCommandController { } /** - * Reload MCP manager with new configs + * Reconnect every configured MCP server and rebind the session's MCP tools. + * + * Disconnects all live connections, rediscovers `.mcp.json` configs, and + * calls `session.refreshMCPTools(...)` so config edits take effect without a + * restart. Public because `/reload-plugins` reuses it alongside `/mcp reload` + * and the config-mutation flows in this controller. */ - async #reloadMCP(): Promise { + async reloadServers(): Promise { if (!this.ctx.mcpManager) { return; } diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index d904bdd2e..cb716b5c7 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -34,7 +34,7 @@ import { import { readMCPConfigFile } from "../mcp/config-writer"; import { resolveMemoryBackend } from "../memory-backend"; import { runPauseScreen } from "../modes/components/pause-screen"; -import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller"; +import { collectMcpServerNames, MCPCommandController } from "../modes/controllers/mcp-command-controller"; import { describeLoopLimitRuntime } from "../modes/loop-limit"; import { theme } from "../modes/theme/theme"; import type { InteractiveModeContext } from "../modes/types"; @@ -2628,13 +2628,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ return commandConsumed(); }, handleTui: async (_command, runtime) => { - // Invalidate registry fs caches and the plugin roots cache so - // listClaudePluginRoots re-reads from disk on next access. - const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd()); - clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); - await runtime.ctx.refreshSkillState(); - await runtime.ctx.refreshSlashCommandState(); - resetCapabilities(); + await reloadTuiPluginState(runtime.ctx); runtime.ctx.showStatus("Plugins reloaded."); runtime.ctx.editor.setText(""); }, @@ -3059,6 +3053,24 @@ export function buildTuiBuiltinSlashCommands(runtime: TuiSlashCommandRuntime): R */ export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray = BUILTIN_SLASH_COMMAND_REGISTRY; +/** + * Reload the interactive session's plugin runtime: invalidate fs/plugin-root + * caches, rediscover skills and file slash commands, reset the capability + * cache, and reconnect MCP servers (rebinding the session's MCP tools). Shared + * by `/reload-plugins`'s TUI handler and the `handle`-adapter's `reloadPlugins` + * hook so both honor the command's documented MCP reload scope (#7189). + */ +async function reloadTuiPluginState(ctx: InteractiveModeContext): Promise { + const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + await ctx.refreshSkillState(); + await ctx.refreshSlashCommandState(); + resetCapabilities(); + if (ctx.mcpManager) { + await new MCPCommandController(ctx).reloadServers(); + } +} + /** * Execute a builtin slash command in the interactive TUI. * @@ -3107,13 +3119,7 @@ export async function executeBuiltinSlashCommand( ctx.showStatus(text); }, refreshCommands: () => ctx.refreshSlashCommandState(), - reloadPlugins: async () => { - const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); - clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); - await ctx.refreshSkillState(); - await ctx.refreshSlashCommandState(); - resetCapabilities(); - }, + reloadPlugins: () => reloadTuiPluginState(ctx), }; const result = await command.handle(parsed, adapted); ctx.editor.setText(""); diff --git a/packages/coding-agent/test/reload-plugins-mcp.test.ts b/packages/coding-agent/test/reload-plugins-mcp.test.ts new file mode 100644 index 000000000..8b3e516bb --- /dev/null +++ b/packages/coding-agent/test/reload-plugins-mcp.test.ts @@ -0,0 +1,65 @@ +/** + * Regression: `/reload-plugins` is documented to reload MCP, so the TUI handler + * must reconnect servers and rebind the session's MCP tool registry — not just + * reset skill/command/capability caches. Before the fix it silently skipped + * MCP, leaving `.mcp.json` edits inactive until process restart (#7189). + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import type { TuiSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types"; +import { getProjectDir, removeWithRetries, setProjectDir } from "@oh-my-pi/pi-utils"; + +const originalProjectDir = getProjectDir(); + +function createFakeCtx(cwd: string) { + const mcpTools = [{ name: "mcp__srv_do" }]; + const mcpManager = { + disconnectAll: vi.fn(async () => {}), + discoverAndConnect: vi.fn(async () => ({ errors: new Map() })), + getTools: vi.fn(() => mcpTools), + }; + const session = { + refreshMCPTools: vi.fn(async (_tools: unknown) => {}), + }; + const ctx = { + mcpManager, + session, + sessionManager: { getCwd: () => cwd }, + refreshSkillState: vi.fn(async () => {}), + refreshSlashCommandState: vi.fn(async () => {}), + showStatus: vi.fn(() => {}), + editor: { setText: vi.fn(() => {}) }, + } as never as InteractiveModeContext; + return { ctx, mcpManager, session, mcpTools }; +} + +describe("/reload-plugins MCP reconnect (#7189)", () => { + let projectDir = ""; + + beforeEach(async () => { + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-reload-plugins-mcp-")); + setProjectDir(projectDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + setProjectDir(originalProjectDir); + await removeWithRetries(projectDir); + }); + + test("reconnects MCP servers and rebinds session MCP tools", async () => { + const { ctx, mcpManager, session, mcpTools } = createFakeCtx(projectDir); + const runtime: TuiSlashCommandRuntime = { ctx }; + + const result = await executeBuiltinSlashCommand("/reload-plugins", runtime); + expect(result).toBe(true); + expect(mcpManager.disconnectAll).toHaveBeenCalledTimes(1); + expect(mcpManager.discoverAndConnect).toHaveBeenCalledTimes(1); + expect(session.refreshMCPTools).toHaveBeenCalledTimes(1); + expect(session.refreshMCPTools).toHaveBeenCalledWith(mcpTools); + }); +});