diff --git a/src/__tests__/commands/alias.test.ts b/src/__tests__/commands/alias.test.ts new file mode 100644 index 0000000..033ef05 --- /dev/null +++ b/src/__tests__/commands/alias.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for src/commands/alias.ts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleAlias } from "../../commands/alias.js"; +import type { AliasDeps } from "../../commands/alias.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDeps(overrides: Partial = {}): AliasDeps { + return { + getAliases: vi.fn().mockResolvedValue({}), + setAlias: vi.fn().mockResolvedValue(undefined), + removeAlias: vi.fn().mockResolvedValue(undefined), + output: vi.fn(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("handleAlias", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("--list", () => { + it("shows empty message when no aliases exist", async () => { + const deps = makeDeps(); + await handleAlias([], { list: true }, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/No aliases/i); + }); + + it("displays aliases in a table", async () => { + const deps = makeDeps({ + getAliases: vi.fn().mockResolvedValue({ + fs: "io.github.domdomegg/filesystem-mcp", + gh: "io.github.modelcontextprotocol/github", + }), + }); + await handleAlias([], { list: true }, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toContain("fs"); + expect(output).toContain("io.github.domdomegg/filesystem-mcp"); + }); + }); + + describe("--remove", () => { + it("removes an existing alias", async () => { + const deps = makeDeps(); + await handleAlias([], { remove: "fs" }, deps); + expect(deps.removeAlias).toHaveBeenCalledWith("fs"); + }); + + it("outputs confirmation after removal", async () => { + const deps = makeDeps(); + await handleAlias([], { remove: "fs" }, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/Removed.*fs/); + }); + }); + + describe("set alias", () => { + it("sets an alias with two positional args", async () => { + const deps = makeDeps(); + await handleAlias(["fs", "io.github.domdomegg/filesystem-mcp"], {}, deps); + expect(deps.setAlias).toHaveBeenCalledWith("fs", "io.github.domdomegg/filesystem-mcp"); + }); + + it("outputs the alias mapping", async () => { + const deps = makeDeps(); + await handleAlias(["fs", "io.github.domdomegg/filesystem-mcp"], {}, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toContain("fs"); + expect(output).toContain("io.github.domdomegg/filesystem-mcp"); + }); + + it("throws when insufficient arguments", async () => { + const deps = makeDeps(); + await expect(handleAlias(["fs"], {}, deps)).rejects.toThrow(/Usage/); + }); + + it("throws when alias contains /", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["bad/name", "some-server"], {}, deps) + ).rejects.toThrow(/letters.*digits.*hyphens.*underscores/); + }); + + it("throws when alias contains .", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["bad.name", "some-server"], {}, deps) + ).rejects.toThrow(/letters.*digits.*hyphens.*underscores/); + }); + + it("throws when alias is empty", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["", "some-server"], {}, deps) + ).rejects.toThrow(/must not be empty/); + }); + + it("throws when alias contains shell metacharacters", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["bad$name", "some-server"], {}, deps) + ).rejects.toThrow(/letters.*digits.*hyphens.*underscores/); + }); + + it("throws when alias exceeds max length", async () => { + const deps = makeDeps(); + const longName = "a".repeat(65); + await expect( + handleAlias([longName, "some-server"], {}, deps) + ).rejects.toThrow(/at most 64/); + }); + + it("throws when server name is empty", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["fs", ""], {}, deps) + ).rejects.toThrow(/must not be empty/); + }); + + it("throws when server name is __proto__", async () => { + const deps = makeDeps(); + await expect( + handleAlias(["fs", "__proto__"], {}, deps) + ).rejects.toThrow(/not allowed/); + }); + }); + + describe("--remove validation", () => { + it("validates alias name before removing", async () => { + const deps = makeDeps(); + await expect( + handleAlias([], { remove: "bad$name" }, deps) + ).rejects.toThrow(/letters.*digits.*hyphens.*underscores/); + expect(deps.removeAlias).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/commands/completions.test.ts b/src/__tests__/commands/completions.test.ts new file mode 100644 index 0000000..bffb211 --- /dev/null +++ b/src/__tests__/commands/completions.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for src/commands/completions.ts + */ + +import { describe, it, expect, vi } from "vitest"; +import { handleCompletions } from "../../commands/completions.js"; +import type { ShellType } from "../../commands/completions.js"; + +describe("handleCompletions", () => { + it("generates bash completions containing mcpm commands", () => { + const output = vi.fn(); + handleCompletions("bash", { output }); + expect(output).toHaveBeenCalledOnce(); + const script = output.mock.calls[0][0] as string; + expect(script).toContain("_mcpm_completions"); + expect(script).toContain("search"); + expect(script).toContain("install"); + expect(script).toContain("disable"); + expect(script).toContain("enable"); + expect(script).toContain("complete -F"); + }); + + it("generates zsh completions with command descriptions", () => { + const output = vi.fn(); + handleCompletions("zsh", { output }); + const script = output.mock.calls[0][0] as string; + expect(script).toContain("compdef _mcpm mcpm"); + expect(script).toContain("search:Search"); + expect(script).toContain("disable:Disable"); + }); + + it("generates fish completions with subcommand completions", () => { + const output = vi.fn(); + handleCompletions("fish", { output }); + const script = output.mock.calls[0][0] as string; + expect(script).toContain("complete -c mcpm"); + expect(script).toContain("__fish_use_subcommand"); + expect(script).toContain("search"); + expect(script).toContain("disable"); + }); + + it("includes client IDs in completions", () => { + const shells: ShellType[] = ["bash", "zsh", "fish"]; + for (const shell of shells) { + const output = vi.fn(); + handleCompletions(shell, { output }); + const script = output.mock.calls[0][0] as string; + expect(script).toContain("claude-desktop"); + expect(script).toContain("cursor"); + expect(script).toContain("vscode"); + expect(script).toContain("windsurf"); + } + }); +}); diff --git a/src/__tests__/commands/disable.test.ts b/src/__tests__/commands/disable.test.ts new file mode 100644 index 0000000..cff2018 --- /dev/null +++ b/src/__tests__/commands/disable.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for src/commands/disable.ts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ClientId } from "../../config/paths.js"; +import type { ConfigAdapter, McpServerEntry } from "../../config/adapters/index.js"; +import { handleDisable } from "../../commands/disable.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAdapter( + clientId: ClientId, + servers: Record = {} +): ConfigAdapter { + return { + clientId, + read: vi.fn().mockResolvedValue(servers), + addServer: vi.fn().mockResolvedValue(undefined), + removeServer: vi.fn().mockResolvedValue(undefined), + setServerDisabled: vi.fn().mockResolvedValue(undefined), + }; +} + +interface DisableDeps { + detectClients: () => Promise; + getAdapter: (clientId: ClientId) => ConfigAdapter; + getConfigPath: (clientId: ClientId) => string; + output: (text: string) => void; +} + +function makeDeps(overrides: Partial = {}): DisableDeps { + return { + detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + makeAdapter(id, { "my-server": { command: "npx", args: ["-y", "my-server"] } }) + ), + getConfigPath: vi.fn().mockImplementation((id: ClientId) => `/fake/${id}/config.json`), + output: vi.fn(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("handleDisable", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("disables a server in the detected client", async () => { + const adapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx" }, + }); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await handleDisable("my-server", {}, deps); + expect(adapter.setServerDisabled).toHaveBeenCalledWith( + "/fake/claude-desktop/config.json", + "my-server", + true + ); + }); + + it("outputs success message", async () => { + const deps = makeDeps(); + await handleDisable("my-server", {}, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/Disabled.*my-server/); + }); + + it("throws when server not found", async () => { + const adapter = makeAdapter("claude-desktop", {}); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await expect(handleDisable("ghost", {}, deps)).rejects.toThrow(/not found/i); + }); + + it("reports already disabled when server has disabled: true", async () => { + const adapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx", disabled: true }, + }); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await handleDisable("my-server", {}, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/already disabled/i); + expect(adapter.setServerDisabled).not.toHaveBeenCalled(); + }); + + it("respects --client filter", async () => { + const claudeAdapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx" }, + }); + const cursorAdapter = makeAdapter("cursor", { + "my-server": { command: "npx" }, + }); + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + id === "claude-desktop" ? claudeAdapter : cursorAdapter + ), + }); + await handleDisable("my-server", { client: "cursor" }, deps); + expect(cursorAdapter.setServerDisabled).toHaveBeenCalled(); + expect(claudeAdapter.setServerDisabled).not.toHaveBeenCalled(); + }); + + it("throws when --client is not installed", async () => { + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]), + }); + await expect( + handleDisable("my-server", { client: "vscode" }, deps) + ).rejects.toThrow(/vscode.*not.*installed/i); + }); + + it("throws when --client is an invalid client id", async () => { + const deps = makeDeps(); + await expect( + handleDisable("my-server", { client: "invalid-client" }, deps) + ).rejects.toThrow(/Unknown client.*invalid-client/); + }); + + it("only disables the enabled client when mixed state", async () => { + const claudeAdapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx", disabled: true }, + }); + const cursorAdapter = makeAdapter("cursor", { + "my-server": { command: "npx" }, + }); + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + id === "claude-desktop" ? claudeAdapter : cursorAdapter + ), + }); + await handleDisable("my-server", {}, deps); + expect(claudeAdapter.setServerDisabled).not.toHaveBeenCalled(); + expect(cursorAdapter.setServerDisabled).toHaveBeenCalledWith( + "/fake/cursor/config.json", + "my-server", + true + ); + }); + + it("disables across multiple clients", async () => { + const claudeAdapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx" }, + }); + const cursorAdapter = makeAdapter("cursor", { + "my-server": { command: "npx" }, + }); + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + id === "claude-desktop" ? claudeAdapter : cursorAdapter + ), + }); + await handleDisable("my-server", {}, deps); + expect(claudeAdapter.setServerDisabled).toHaveBeenCalled(); + expect(cursorAdapter.setServerDisabled).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/commands/enable.test.ts b/src/__tests__/commands/enable.test.ts new file mode 100644 index 0000000..8b3deb2 --- /dev/null +++ b/src/__tests__/commands/enable.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for src/commands/enable.ts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ClientId } from "../../config/paths.js"; +import type { ConfigAdapter, McpServerEntry } from "../../config/adapters/index.js"; +import { handleEnable } from "../../commands/enable.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAdapter( + clientId: ClientId, + servers: Record = {} +): ConfigAdapter { + return { + clientId, + read: vi.fn().mockResolvedValue(servers), + addServer: vi.fn().mockResolvedValue(undefined), + removeServer: vi.fn().mockResolvedValue(undefined), + setServerDisabled: vi.fn().mockResolvedValue(undefined), + }; +} + +interface EnableDeps { + detectClients: () => Promise; + getAdapter: (clientId: ClientId) => ConfigAdapter; + getConfigPath: (clientId: ClientId) => string; + output: (text: string) => void; +} + +function makeDeps(overrides: Partial = {}): EnableDeps { + return { + detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + makeAdapter(id, { "my-server": { command: "npx", disabled: true } }) + ), + getConfigPath: vi.fn().mockImplementation((id: ClientId) => `/fake/${id}/config.json`), + output: vi.fn(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("handleEnable", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("enables a disabled server", async () => { + const adapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx", disabled: true }, + }); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await handleEnable("my-server", {}, deps); + expect(adapter.setServerDisabled).toHaveBeenCalledWith( + "/fake/claude-desktop/config.json", + "my-server", + false + ); + }); + + it("outputs success message", async () => { + const deps = makeDeps(); + await handleEnable("my-server", {}, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/Enabled.*my-server/); + }); + + it("throws when server not found", async () => { + const adapter = makeAdapter("claude-desktop", {}); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await expect(handleEnable("ghost", {}, deps)).rejects.toThrow(/not found/i); + }); + + it("reports already enabled when server is not disabled", async () => { + const adapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx" }, + }); + const deps = makeDeps({ getAdapter: vi.fn().mockReturnValue(adapter) }); + await handleEnable("my-server", {}, deps); + const output = (deps.output as ReturnType).mock.calls.flat().join(" "); + expect(output).toMatch(/already enabled/i); + expect(adapter.setServerDisabled).not.toHaveBeenCalled(); + }); + + it("respects --client filter", async () => { + const claudeAdapter = makeAdapter("claude-desktop", { + "my-server": { command: "npx", disabled: true }, + }); + const cursorAdapter = makeAdapter("cursor", { + "my-server": { command: "npx", disabled: true }, + }); + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop", "cursor"] as ClientId[]), + getAdapter: vi.fn().mockImplementation((id: ClientId) => + id === "claude-desktop" ? claudeAdapter : cursorAdapter + ), + }); + await handleEnable("my-server", { client: "cursor" }, deps); + expect(cursorAdapter.setServerDisabled).toHaveBeenCalled(); + expect(claudeAdapter.setServerDisabled).not.toHaveBeenCalled(); + }); + + it("throws when --client is not installed", async () => { + const deps = makeDeps({ + detectClients: vi.fn().mockResolvedValue(["claude-desktop"] as ClientId[]), + }); + await expect( + handleEnable("my-server", { client: "vscode" }, deps) + ).rejects.toThrow(/vscode.*not.*installed/i); + }); + + it("throws when --client is an invalid client id", async () => { + const deps = makeDeps(); + await expect( + handleEnable("my-server", { client: "invalid-client" }, deps) + ).rejects.toThrow(/Unknown client.*invalid-client/); + }); +}); diff --git a/src/__tests__/store/aliases.test.ts b/src/__tests__/store/aliases.test.ts new file mode 100644 index 0000000..6525f32 --- /dev/null +++ b/src/__tests__/store/aliases.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for src/store/aliases.ts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getAliases, setAlias, removeAlias, resolveAlias } from "../../store/aliases.js"; + +// Mock the store index to avoid real filesystem I/O. +vi.mock("../../store/index.js", () => { + let store: Record = {}; + return { + readJson: vi.fn().mockImplementation(async (filename: string) => { + return store[filename] ?? null; + }), + writeJson: vi.fn().mockImplementation(async (filename: string, data: unknown) => { + store[filename] = data; + }), + // Expose for tests to reset state + _resetStore: () => { store = {}; }, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const storeModule = await import("../../store/index.js") as any; + +describe("aliases store", () => { + beforeEach(() => { + storeModule._resetStore(); + vi.clearAllMocks(); + }); + + it("returns empty object when no aliases file exists", async () => { + const result = await getAliases(); + expect(result).toEqual({}); + }); + + it("sets and retrieves an alias", async () => { + await setAlias("fs", "io.github.domdomegg/filesystem-mcp"); + const aliases = await getAliases(); + expect(aliases["fs"]).toBe("io.github.domdomegg/filesystem-mcp"); + }); + + it("overwrites an existing alias", async () => { + await setAlias("fs", "old-server"); + await setAlias("fs", "new-server"); + const aliases = await getAliases(); + expect(aliases["fs"]).toBe("new-server"); + }); + + it("removes an alias", async () => { + await setAlias("fs", "some-server"); + await removeAlias("fs"); + const aliases = await getAliases(); + expect(aliases["fs"]).toBeUndefined(); + }); + + it("throws when removing non-existent alias", async () => { + await expect(removeAlias("nonexistent")).rejects.toThrow(/not found/i); + }); + + it("resolves alias to server name", async () => { + await setAlias("fs", "io.github.domdomegg/filesystem-mcp"); + const resolved = await resolveAlias("fs"); + expect(resolved).toBe("io.github.domdomegg/filesystem-mcp"); + }); + + it("returns input when no alias matches", async () => { + const resolved = await resolveAlias("io.github.domdomegg/filesystem-mcp"); + expect(resolved).toBe("io.github.domdomegg/filesystem-mcp"); + }); +}); diff --git a/src/commands/alias.ts b/src/commands/alias.ts new file mode 100644 index 0000000..fe8196e --- /dev/null +++ b/src/commands/alias.ts @@ -0,0 +1,144 @@ +/** + * `mcpm alias` command handler. + * + * Manages short aliases for long MCP server names. + * + * mcpm alias fs io.github.domdomegg/filesystem-mcp + * mcpm alias --list + * mcpm alias --remove fs + * + * Aliases are stored in ~/.mcpm/aliases.json and resolved automatically + * in install, info, remove, enable, and disable commands. + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import Table from "cli-table3"; +import { stdoutOutput } from "../utils/output.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AliasMap = Record; + +export interface AliasDeps { + getAliases: () => Promise; + setAlias: (alias: string, serverName: string) => Promise; + removeAlias: (alias: string) => Promise; + output: (text: string) => void; +} + +export interface AliasOptions { + list?: boolean; + remove?: string; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** Alias names must be short, alphanumeric identifiers (letters, digits, hyphens, underscores). */ +const ALIAS_NAME_RE = /^[\w-]+$/; +const ALIAS_MAX_LENGTH = 64; + +function validateAliasName(alias: string): void { + if (alias.length === 0) { + throw new Error("Alias name must not be empty."); + } + if (alias.length > ALIAS_MAX_LENGTH) { + throw new Error(`Alias name must be at most ${ALIAS_MAX_LENGTH} characters.`); + } + if (!ALIAS_NAME_RE.test(alias)) { + throw new Error( + "Alias names must contain only letters, digits, hyphens, and underscores." + ); + } +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleAlias( + args: string[], + options: AliasOptions, + deps: AliasDeps +): Promise { + const { getAliases, setAlias, removeAlias, output } = deps; + + // --list: show all aliases + if (options.list) { + const aliases = await getAliases(); + const entries = Object.entries(aliases); + if (entries.length === 0) { + output("No aliases defined. Create one: mcpm alias "); + return; + } + + const table = new Table({ + head: [chalk.cyan("Alias"), chalk.cyan("Server Name")], + style: { head: [], border: [] }, + }); + + for (const [alias, name] of entries) { + table.push([chalk.yellow(alias), chalk.white(name)]); + } + + output(table.toString()); + return; + } + + // --remove: delete an alias + if (options.remove) { + validateAliasName(options.remove); + await removeAlias(options.remove); + output(`Removed alias '${options.remove}'.`); + return; + } + + // Set alias: mcpm alias + if (args.length < 2) { + throw new Error("Usage: mcpm alias \n mcpm alias --list\n mcpm alias --remove "); + } + + const [alias, serverName] = args; + validateAliasName(alias); + + if (serverName.length === 0) { + throw new Error("Server name must not be empty."); + } + if (serverName === "__proto__" || serverName === "constructor" || serverName === "prototype") { + throw new Error("Server name is not allowed."); + } + + await setAlias(alias, serverName); + output(`Alias '${alias}' → '${serverName}'`); +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +export function registerAliasCommand(program: Command): void { + program + .command("alias [args...]") + .description("Create short aliases for MCP server names") + .option("-l, --list", "List all defined aliases") + .option("-r, --remove ", "Remove an alias") + .action(async (args: string[], options: AliasOptions) => { + const { getAliases, setAlias, removeAlias } = await import("../store/aliases.js"); + + try { + await handleAlias(args, options, { + getAliases, + setAlias, + removeAlias, + output: stdoutOutput, + }); + } catch (err) { + console.error(chalk.red((err as Error).message)); + process.exit(1); + } + }); +} diff --git a/src/commands/completions.ts b/src/commands/completions.ts new file mode 100644 index 0000000..dbbe3f7 --- /dev/null +++ b/src/commands/completions.ts @@ -0,0 +1,186 @@ +/** + * `mcpm completions ` command handler. + * + * Generates shell completion scripts for bash, zsh, and fish. + * Users pipe the output into their shell config to enable tab-completion. + * + * Usage: + * mcpm completions bash >> ~/.bashrc + * mcpm completions zsh >> ~/.zshrc + * mcpm completions fish > ~/.config/fish/completions/mcpm.fish + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import { stdoutOutput } from "../utils/output.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ShellType = "bash" | "zsh" | "fish"; + +export interface CompletionsDeps { + output: (text: string) => void; +} + +// --------------------------------------------------------------------------- +// Shell scripts +// --------------------------------------------------------------------------- + +const SUBCOMMANDS = [ + "search", "install", "info", "list", "remove", "audit", "update", + "doctor", "init", "import", "serve", "disable", "enable", "completions", +]; + +const CLIENT_IDS = ["claude-desktop", "cursor", "vscode", "windsurf"]; + +function bashScript(): string { + return `# mcpm bash completions +# Add to ~/.bashrc: eval "$(mcpm completions bash)" +_mcpm_completions() { + local cur prev commands + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + commands="${SUBCOMMANDS.join(" ")}" + + case "\${prev}" in + mcpm) + COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") ) + return 0 + ;; + --client|-c) + COMPREPLY=( $(compgen -W "${CLIENT_IDS.join(" ")}" -- "\${cur}") ) + return 0 + ;; + init) + COMPREPLY=( $(compgen -W "developer data web" -- "\${cur}") ) + return 0 + ;; + completions) + COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") ) + return 0 + ;; + esac + + if [[ "\${cur}" == -* ]]; then + COMPREPLY=( $(compgen -W "--help --json --yes --client --limit --force" -- "\${cur}") ) + return 0 + fi +} +complete -F _mcpm_completions mcpm`; +} + +function zshScript(): string { + return `# mcpm zsh completions +# Add to ~/.zshrc: eval "$(mcpm completions zsh)" +_mcpm() { + local -a commands + commands=( + 'search:Search the MCP registry for servers' + 'install:Install an MCP server with trust assessment' + 'info:Show full details for an MCP server' + 'list:List all installed MCP servers' + 'remove:Remove an MCP server from client configs' + 'audit:Scan all installed servers for trust assessment' + 'update:Check for newer versions of installed servers' + 'doctor:Check MCP setup health' + 'init:Install a curated starter pack' + 'import:Import existing MCP configs' + 'serve:Start mcpm as an MCP server' + 'disable:Disable an MCP server without removing it' + 'enable:Re-enable a previously disabled server' + 'completions:Generate shell completion scripts' + ) + + _arguments -C \\ + '1:command:->command' \\ + '*::arg:->args' + + case "$state" in + command) + _describe 'mcpm command' commands + ;; + args) + case "\${words[1]}" in + init) + _values 'pack' developer data web + ;; + completions) + _values 'shell' bash zsh fish + ;; + remove|disable|enable|install|info|search) + _arguments '--client[Target client]:client:(${CLIENT_IDS.join(" ")})' + ;; + esac + ;; + esac +} +compdef _mcpm mcpm`; +} + +function fishScript(): string { + return `# mcpm fish completions +# Save to: ~/.config/fish/completions/mcpm.fish +complete -c mcpm -e +complete -c mcpm -n '__fish_use_subcommand' -a search -d 'Search the MCP registry' +complete -c mcpm -n '__fish_use_subcommand' -a install -d 'Install an MCP server' +complete -c mcpm -n '__fish_use_subcommand' -a info -d 'Show server details' +complete -c mcpm -n '__fish_use_subcommand' -a list -d 'List installed servers' +complete -c mcpm -n '__fish_use_subcommand' -a remove -d 'Remove an MCP server' +complete -c mcpm -n '__fish_use_subcommand' -a audit -d 'Scan installed servers' +complete -c mcpm -n '__fish_use_subcommand' -a update -d 'Check for updates' +complete -c mcpm -n '__fish_use_subcommand' -a doctor -d 'Check MCP setup health' +complete -c mcpm -n '__fish_use_subcommand' -a init -d 'Install a starter pack' +complete -c mcpm -n '__fish_use_subcommand' -a import -d 'Import existing configs' +complete -c mcpm -n '__fish_use_subcommand' -a serve -d 'Start as MCP server' +complete -c mcpm -n '__fish_use_subcommand' -a disable -d 'Disable an MCP server' +complete -c mcpm -n '__fish_use_subcommand' -a enable -d 'Re-enable an MCP server' +complete -c mcpm -n '__fish_use_subcommand' -a completions -d 'Generate completions' +complete -c mcpm -n '__fish_seen_subcommand_from init' -a 'developer data web' +complete -c mcpm -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish' +complete -c mcpm -n '__fish_seen_subcommand_from remove disable enable install info' -l client -s c -a '${CLIENT_IDS.join(" ")}' -d 'Target client' +complete -c mcpm -l json -d 'Output as JSON' +complete -c mcpm -l yes -s y -d 'Skip confirmation' +complete -c mcpm -l help -s h -d 'Show help'`; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export function handleCompletions(shell: ShellType, deps: CompletionsDeps): void { + const { output } = deps; + + switch (shell) { + case "bash": + output(bashScript()); + break; + case "zsh": + output(zshScript()); + break; + case "fish": + output(fishScript()); + break; + } +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +const VALID_SHELLS: ShellType[] = ["bash", "zsh", "fish"]; + +export function registerCompletionsCommand(program: Command): void { + program + .command("completions ") + .description("Generate shell completion scripts (bash, zsh, fish)") + .action((shell: string) => { + if (!VALID_SHELLS.includes(shell as ShellType)) { + console.error(chalk.red(`Invalid shell: "${shell}". Choose from: ${VALID_SHELLS.join(", ")}`)); + process.exit(1); + } + handleCompletions(shell as ShellType, { output: stdoutOutput }); + }); +} diff --git a/src/commands/disable.ts b/src/commands/disable.ts new file mode 100644 index 0000000..307070c --- /dev/null +++ b/src/commands/disable.ts @@ -0,0 +1,57 @@ +/** + * `mcpm disable ` command handler. + * + * Disables a named MCP server across all (or a specific) client config files + * by setting `"disabled": true` on the entry. The server remains in config + * but will not be loaded by the client. + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import { handleToggleServer } from "./toggle.js"; +import type { ToggleDeps, ToggleOptions } from "./toggle.js"; +import { stdoutOutput } from "../utils/output.js"; + +// Re-export types for backwards compatibility with tests and barrel. +export type DisableDeps = ToggleDeps; +export type DisableOptions = ToggleOptions; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleDisable( + name: string, + options: DisableOptions, + deps: DisableDeps +): Promise { + return handleToggleServer(name, true, options, deps); +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +export function registerDisableCommand(program: Command): void { + program + .command("disable ") + .description("Disable an MCP server without removing it from config") + .option("-c, --client ", "only disable in this specific client") + .action(async (name: string, options: DisableOptions) => { + const { detectInstalledClients } = await import("../config/detector.js"); + const { getConfigPath } = await import("../config/paths.js"); + const { getAdapter } = await import("../config/adapters/factory.js"); + + try { + await handleDisable(name, options, { + detectClients: detectInstalledClients, + getAdapter, + getConfigPath, + output: stdoutOutput, + }); + } catch (err) { + console.error(chalk.red((err as Error).message)); + process.exit(1); + } + }); +} diff --git a/src/commands/enable.ts b/src/commands/enable.ts new file mode 100644 index 0000000..2d94eb5 --- /dev/null +++ b/src/commands/enable.ts @@ -0,0 +1,56 @@ +/** + * `mcpm enable ` command handler. + * + * Re-enables a previously disabled MCP server across all (or a specific) + * client config files by removing the `"disabled": true` flag. + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import { handleToggleServer } from "./toggle.js"; +import type { ToggleDeps, ToggleOptions } from "./toggle.js"; +import { stdoutOutput } from "../utils/output.js"; + +// Re-export types for backwards compatibility with tests and barrel. +export type EnableDeps = ToggleDeps; +export type EnableOptions = ToggleOptions; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleEnable( + name: string, + options: EnableOptions, + deps: EnableDeps +): Promise { + return handleToggleServer(name, false, options, deps); +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +export function registerEnableCommand(program: Command): void { + program + .command("enable ") + .description("Re-enable a previously disabled MCP server") + .option("-c, --client ", "only enable in this specific client") + .action(async (name: string, options: EnableOptions) => { + const { detectInstalledClients } = await import("../config/detector.js"); + const { getConfigPath } = await import("../config/paths.js"); + const { getAdapter } = await import("../config/adapters/factory.js"); + + try { + await handleEnable(name, options, { + detectClients: detectInstalledClients, + getAdapter, + getConfigPath, + output: stdoutOutput, + }); + } catch (err) { + console.error(chalk.red((err as Error).message)); + process.exit(1); + } + }); +} diff --git a/src/commands/index.ts b/src/commands/index.ts index e8b9b98..01e56e0 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -17,6 +17,10 @@ import { registerUpdateCommand } from "./update.js"; import { registerInitCommand } from "./init.js"; import { registerImportCommand } from "./import.js"; import { registerServeCommand } from "./serve.js"; +import { registerDisableCommand } from "./disable.js"; +import { registerEnableCommand } from "./enable.js"; +import { registerCompletionsCommand } from "./completions.js"; +import { registerAliasCommand } from "./alias.js"; export { registerSearch } from "./search.js"; export { registerInstallCommand, handleInstall, resolveInstallEntry, formatTrustScore } from "./install.js"; @@ -33,6 +37,16 @@ export type { PackDefinition, InitDeps, InitOptions } from "./init.js"; export { registerImportCommand, handleImport, checkFirstRun } from "./import.js"; export type { ImportDeps, ImportOptions } from "./import.js"; export { registerServeCommand } from "./serve.js"; +export { handleToggleServer } from "./toggle.js"; +export type { ToggleDeps, ToggleOptions } from "./toggle.js"; +export { registerDisableCommand, handleDisable } from "./disable.js"; +export type { DisableDeps, DisableOptions } from "./disable.js"; +export { registerEnableCommand, handleEnable } from "./enable.js"; +export type { EnableDeps, EnableOptions } from "./enable.js"; +export { registerCompletionsCommand, handleCompletions } from "./completions.js"; +export type { ShellType, CompletionsDeps } from "./completions.js"; +export { registerAliasCommand, handleAlias } from "./alias.js"; +export type { AliasDeps, AliasOptions } from "./alias.js"; export function registerCommands(program: Command): void { registerSearch(program); @@ -46,4 +60,8 @@ export function registerCommands(program: Command): void { registerInitCommand(program); registerImportCommand(program); registerServeCommand(program); + registerDisableCommand(program); + registerEnableCommand(program); + registerCompletionsCommand(program); + registerAliasCommand(program); } diff --git a/src/commands/list.ts b/src/commands/list.ts index 623ffb8..d442709 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -102,17 +102,20 @@ export async function handleList( head: [ chalk.cyan("Client"), chalk.cyan("Server Name"), + chalk.cyan("Status"), chalk.cyan("Command/URL"), ], style: { head: [], border: [] }, wordWrap: true, - colWidths: [18, 30, 50], + colWidths: [18, 28, 10, 42], }); for (const { client, serverName, entry } of rows) { + const status = entry.disabled ? chalk.yellow("disabled") : chalk.green("active"); table.push([ chalk.yellow(client), chalk.white(serverName), + status, chalk.dim(formatMcpEntryCommand(entry)), ]); } diff --git a/src/commands/toggle.ts b/src/commands/toggle.ts new file mode 100644 index 0000000..d474f0a --- /dev/null +++ b/src/commands/toggle.ts @@ -0,0 +1,95 @@ +/** + * Shared handler for `mcpm disable` and `mcpm enable`. + * + * Both commands toggle the `disabled` flag on server entries across client + * configs. This module eliminates the duplication between them by + * parameterising the toggle direction. + */ + +import type { ClientId } from "../config/paths.js"; +import { CLIENT_IDS } from "../config/paths.js"; +import type { ConfigAdapter, McpServerEntry } from "../config/adapters/index.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ToggleDeps { + detectClients: () => Promise; + getAdapter: (clientId: ClientId) => ConfigAdapter; + getConfigPath: (clientId: ClientId) => string; + output: (text: string) => void; +} + +export interface ToggleOptions { + client?: string; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function handleToggleServer( + name: string, + disabled: boolean, + options: ToggleOptions, + deps: ToggleDeps +): Promise { + const { detectClients, getAdapter, getConfigPath, output } = deps; + const action = disabled ? "Disabled" : "Enabled"; + const alreadyState = disabled ? "disabled" : "enabled"; + + let clients = await detectClients(); + + if (options.client !== undefined) { + if (!CLIENT_IDS.includes(options.client as ClientId)) { + throw new Error( + `Unknown client "${options.client}". Valid values: ${CLIENT_IDS.join(", ")}.` + ); + } + const target = options.client as ClientId; + if (!clients.includes(target)) { + throw new Error(`Client "${target}" is not installed on this machine.`); + } + clients = [target]; + } + + // Single-pass: read each client config once, classify into buckets. + const alreadyDone: ClientId[] = []; + const toToggle: Array<{ clientId: ClientId; configPath: string }> = []; + + for (const clientId of clients) { + const adapter = getAdapter(clientId); + const configPath = getConfigPath(clientId); + const servers: Record = await adapter.read(configPath); + + if (!Object.prototype.hasOwnProperty.call(servers, name)) { + continue; + } + + const isCurrentlyDisabled = servers[name].disabled === true; + if (isCurrentlyDisabled === disabled) { + alreadyDone.push(clientId); + } else { + toToggle.push({ clientId, configPath }); + } + } + + if (alreadyDone.length === 0 && toToggle.length === 0) { + const scope = options.client ? `client "${options.client}"` : "any client config"; + throw new Error(`Server '${name}' not found in ${scope}.`); + } + + if (toToggle.length === 0) { + output(`Server '${name}' is already ${alreadyState} in ${alreadyDone.join(", ")}.`); + return; + } + + for (const { clientId, configPath } of toToggle) { + const adapter = getAdapter(clientId); + await adapter.setServerDisabled(configPath, name, disabled); + } + + const toggledIds = toToggle.map((t) => t.clientId); + output(`${action} '${name}' in ${toggledIds.join(", ")}.`); +} diff --git a/src/config/adapters/base.ts b/src/config/adapters/base.ts index c0b9443..82b1702 100644 --- a/src/config/adapters/base.ts +++ b/src/config/adapters/base.ts @@ -135,6 +135,36 @@ export abstract class BaseAdapter implements ConfigAdapter { await this.writeAtomic(configPath, updated, raw); } + + async setServerDisabled(configPath: string, name: string, disabled: boolean): Promise { + const raw = await this.readRaw(configPath); + const existing = (raw[this.rootKey] ?? {}) as Record; + + if (!Object.prototype.hasOwnProperty.call(existing, name)) { + throw new Error( + `Server "${name}" not found in ${this.clientId} config.` + ); + } + + const entry = { ...existing[name] }; + if (disabled) { + entry.disabled = true; + } else { + delete entry.disabled; + } + + const updatedServers: Record = { + ...existing, + [name]: entry, + }; + + const updated: Record = { + ...raw, + [this.rootKey]: updatedServers, + }; + + await this.writeAtomic(configPath, updated, raw); + } } // --------------------------------------------------------------------------- diff --git a/src/config/adapters/index.ts b/src/config/adapters/index.ts index 360bc08..0ddf17e 100644 --- a/src/config/adapters/index.ts +++ b/src/config/adapters/index.ts @@ -18,6 +18,7 @@ export interface McpServerEntry { env?: Record; url?: string; headers?: Record; + disabled?: boolean; } /** @@ -49,4 +50,7 @@ export interface ConfigAdapter { /** Remove a server entry. Throws if the server name is not found. */ removeServer(configPath: string, name: string): Promise; + + /** Set or clear the disabled flag on a server entry. Throws if not found. */ + setServerDisabled(configPath: string, name: string, disabled: boolean): Promise; } diff --git a/src/store/aliases.ts b/src/store/aliases.ts new file mode 100644 index 0000000..7efec8f --- /dev/null +++ b/src/store/aliases.ts @@ -0,0 +1,49 @@ +/** + * Manages server name aliases in ~/.mcpm/aliases.json. + * + * Aliases map short names to full server names, e.g.: + * "fs" → "io.github.domdomegg/filesystem-mcp" + */ + +import { readJson, writeJson } from "./index.js"; + +const FILENAME = "aliases.json"; + +export type AliasMap = Record; + +/** + * Returns all aliases. Returns an empty object if none are stored. + */ +export async function getAliases(): Promise { + const data = await readJson(FILENAME); + return data === null ? {} : { ...data }; +} + +/** + * Sets an alias. Overwrites if it already exists. + */ +export async function setAlias(alias: string, serverName: string): Promise { + const current = await getAliases(); + const updated: AliasMap = { ...current, [alias]: serverName }; + await writeJson(FILENAME, updated); +} + +/** + * Removes an alias. Throws if the alias does not exist. + */ +export async function removeAlias(alias: string): Promise { + const current = await getAliases(); + if (!Object.prototype.hasOwnProperty.call(current, alias)) { + throw new Error(`Alias "${alias}" not found.`); + } + const { [alias]: _removed, ...remaining } = current; + await writeJson(FILENAME, remaining); +} + +/** + * Resolves an alias to a server name. Returns the input if no alias exists. + */ +export async function resolveAlias(nameOrAlias: string): Promise { + const aliases = await getAliases(); + return aliases[nameOrAlias] ?? nameOrAlias; +}