npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

agent-mcp-manager

v0.1.0

Published

Programmatic add/link/unlink for MCP servers across 23 AI coding agents (Claude Code, Claude Desktop, Cursor, VS Code, Codex, Gemini CLI, Zed, Cline, OpenCode, Goose, Kiro, Windsurf, and more). Functional API with dry-run support.

Readme

agent-mcp-manager

Programmatic add/link/unlink for Model Context Protocol servers across 23 AI coding agents. Functional API, dry-run capable, 23-client catalog with per-client shape declarations.

[!IMPORTANT] v0.1.0 is stable. The createMcpManager class API from the 0.0.x line has been removed and replaced with a functional surface. See the Migration guide below for a mechanical translation table. If you cannot migrate immediately, pin agent-mcp-manager@^0.0.3; that line stays on npm unmaintained.

Semver applies from 0.1.0 onward: breaking changes bump minor until 1.0.0, patches are safe to take within a minor.

agent-mcp-manager writes MCP server entries into the real config files that AI coding agents read on launch. Claude Desktop's claude_desktop_config.json, Cursor's ~/.cursor/mcp.json, VS Code's mcp.json, Codex's ~/.codex/config.toml, and 19 others across JSON / JSONC / YAML / TOML. It targets embedders (IDE plugins, internal tools, enterprise onboarding flows, custom installers) that need to register MCP servers programmatically across many agents.

Install

bun add agent-mcp-manager
# or: npm i, pnpm add, yarn add

Quick start

import { link, disconnect, rescan, bind } from 'agent-mcp-manager'

const workspaceDir = '~/.myapp/mcp'

// The server is caller-owned data. No pre-registration step.
const github = {
  name: 'github',
  spec: {
    transport: 'http',
    url: 'https://api.githubcopilot.com/mcp',
    headers: { Authorization: `Bearer ${process.env.GH_TOKEN}` },
  },
} as const

// One link call = one atomic operation:
//   - upserts the manifest server entry
//   - writes the entry to the agent's config file
await link(workspaceDir, { server: github, agent: 'cursor' })
await link(workspaceDir, { server: github, agent: 'claude-code' })
await link(workspaceDir, { server: github, agent: 'vscode' })

// Later, disconnect one agent without touching the others.
await disconnect(workspaceDir, {
  serverName: 'github',
  agent: 'cursor',
  removeIfLast: true, // drop the manifest entry only if no agents remain
})

// Periodically check that on-disk configs still match the manifest.
const report = await rescan(workspaceDir)
if (report.drifted.length > 0 || report.missing.length > 0) {
  // ... report / auto-heal / prompt the user
}

// For consumers who use the same workspaceDir across many calls:
const mgr = bind(workspaceDir)
await mgr.link({ server: github, agent: 'gemini' })

API reference

Verbs

| Verb | Signature | Purpose | |---|---|---| | link | (workspaceDir, {server, agent, scope?, projectRoot?, configPath?, allowOverwrite?}) => Promise<LinkPlanSummary> | Upsert the manifest server entry AND write the server into the agent's config file. Last-write-wins on the manifest spec. | | unlink | (workspaceDir, {serverName, agent, scope?, projectRoot?, configPath?}) => Promise<UnlinkPlanSummary> | Remove one agent's entry from its config file and drop the manifest link. No-op when the manifest has no such link. | | disconnect | (workspaceDir, {serverName, agent, scope?, projectRoot?, removeIfLast?}) => Promise<DisconnectPlanSummary> | Unlink one agent AND drop the manifest entry when no other agents remain linked to it. Never touches other agents' config files. The primitive that closes issue #63. | | remove | (workspaceDir, {serverName, unlinkFirst?}) => Promise<RemovePlanSummary> | Drop the manifest entry AND unlink every currently-linked agent's config file. | | list | (workspaceDir) => Promise<ManifestServerEntry[]> | Every server in the manifest. | | listLinks | (workspaceDir, {serverNames?, agents?}?) => Promise<ListedLink[]> | Every (server, agent, configPath) triple in the manifest. Filter by server name or agent. | | rescan | (workspaceDir, {agents?}?) => Promise<RescanReport> | Diff manifest links against disk. Reports verified / drifted / missing entries. See rescan section below. | | isInstalled | ({agents, scope?, projectRoot?}) => Promise<Partial<Record<AgentId, boolean>>> | Batch check whether each agent is present on the machine (union of install-fingerprint, config file, and config-parent signals). Predicts what link() would throw. See isInstalled section below. | | bind | (workspaceDir) => BoundApi | Sugar for calling all verbs with the same workspaceDir. Stateless: every method still runs readState -> plan -> applyPlan. |

The server value

Every mutating call takes an McpServer value:

interface McpServer {
  name: string          // manifest key + entry name in the agent's config file
  spec: McpServerSpec   // how the server is invoked (stdio | sse | http)
}

type McpServerSpec =
  | { transport: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
  | { transport: 'sse';   url: string; headers?: Record<string, string> }
  | { transport: 'http';  url: string; headers?: Record<string, string> }

The name is trimmed before being used as the manifest key. link({server: {name: ' gh ', ...}}) persists under 'gh'.

Errors

Every error extends McpManagerError. instanceof checks are safe across module boundaries.

| Error | Thrown by | Meaning | |---|---|---| | AgentNotSupportedError | any verb | Agent id not in the catalog. | | ForeignEntryError | link | On-disk entry under the target name was not put there by the manifest. Pass allowOverwrite: true to take ownership. | | UnsupportedTransportError | link | Transport not accepted by this agent at this scope. Details include the accepted set and a per-agent hint. | | InvalidServerSpecError | link | Server has an empty name, or the spec is missing required fields (empty command, empty url, unknown transport). | | UnresolvedConfigPathError | any verb that needs the on-disk path | Cannot resolve the agent's config file on this OS (e.g., project scope requested without projectRoot, env vars unset). | | AgentNotInstalledError | link | Neither the agent's config file nor its parent directory exists on disk. The agent has either not been installed or has been installed but never launched. Fields: .agent, .configPath, .parentDir. Precheck with isInstalled({agents}) to avoid the throw. |

Note: v0.0.3's ServerNotFoundError no longer applies to link, since the server is passed in on every call.

Return summaries

Every mutating verb returns a summary describing what actually happened:

interface LinkPlanSummary {
  serverName: string
  agent: AgentId
  scope: AgentScope
  created: boolean           // true if no prior link existed for this agent; false if we replaced one
  overwroteForeign: boolean  // true if allowOverwrite: true replaced an unmanaged entry
}

interface UnlinkPlanSummary {
  serverName: string
  agent: AgentId
  scope: AgentScope
  removed: boolean           // true if there was actually a link to remove
}

interface DisconnectPlanSummary {
  serverName: string
  agent: AgentId
  scope: AgentScope
  unlinked: boolean          // true if we removed a link record
  removedManifest: boolean   // true if we dropped the manifest entry (last link + removeIfLast)
}

interface RemovePlanSummary {
  serverName: string
  unlinkedAgents: AgentId[]  // every agent whose config file was rewritten
  removedManifest: boolean
}

rescan: detecting drift between manifest and disk

The library maintains its own workspace manifest (<workspaceDir>/manifest.json) that records every server you've linked and which agent's config file received it. rescan compares that manifest against what's actually on disk and returns a report:

interface RescanReport {
  verified: ReadonlyArray<{ serverName: string; agent: AgentId; configPath: string }>
  drifted:  ReadonlyArray<{ serverName: string; agent: AgentId; scope: AgentScope; configPath: string; reason: string }>
  missing:  ReadonlyArray<{ serverName: string; agent: AgentId; scope: AgentScope; configPath: string; reason: string }>
}

When to call it

rescan is your source of truth whenever the on-disk state may have drifted from what the library last wrote. Common triggers:

  • After a user edits the config file directly. Someone opens ~/.cursor/mcp.json in a text editor and deletes an entry. The manifest still thinks it's there.
  • After you change a spec. link({server: A}) then link({server: A_prime, agent: 'gemini'}) where the name is the same but the spec differs. The manifest holds the newest spec; A's previous linkers (cursor, vscode, ...) still have the old spec on disk. rescan won't report drift on presence, but see the note below on how to interpret it.
  • On app startup. UI shells that display "which servers are linked to which agents" should call rescan on load, not trust the manifest alone.
  • Before batching writes. If you're about to link a large batch, rescan first to detect any pre-existing drift you should surface to the user rather than silently overwriting.

What each bucket means

  • verified: manifest says agent X has server Y linked to configPath Z, AND that file exists AND the emitter finds a matching entry inside it. Everything is aligned.
  • drifted: manifest recorded a link, the file exists, but the emitter can't find an entry under that name. Someone (user, another tool, a partial write) removed the entry from disk. The manifest still remembers the link.
  • missing: the config file itself is gone (either the file doesn't exist on disk, or you didn't include the agent in the readState call). The link record survives.

Common patterns

Detect drift and prompt to re-link. The most common consumer flow:

const report = await rescan(workspaceDir)
for (const drift of report.drifted) {
  console.log(`${drift.agent}'s ${drift.configPath} is missing '${drift.serverName}'`)
  // Ask the user; on confirm, re-link using the current spec stored in the manifest.
  const servers = await list(workspaceDir)
  const server = servers.find((s) => s.name === drift.serverName)
  if (server) {
    await link(workspaceDir, {
      server: { name: server.name, spec: server.spec },
      agent: drift.agent,
    })
  }
}

Filter by agent. If you only care about drift for the agents you actively support, pass a filter:

await rescan(workspaceDir, { agents: ['cursor', 'vscode'] })

Report-only in CI. rescan performs zero writes and returns a plain data value. It's safe to run in a CI check that verifies your dev workspace hasn't drifted:

const { drifted, missing } = await rescan(workspaceDir)
if (drifted.length + missing.length > 0) {
  console.error('MCP config drift detected:', { drifted, missing })
  process.exit(1)
}

What rescan does NOT do

  • It does not detect spec drift (an entry exists on disk but was written with a different command/url than the manifest's current spec). The current implementation only checks for entry presence. Spec-drift detection is a v0.2.0 candidate; today, you can compare list(workspaceDir)[i].spec against a parse of the config file yourself if you need it.
  • It does not scan for unmanaged entries (entries on disk that the manifest never wrote). v0.0.3 exposed this via RescanResult.unmanaged; v0.1.0 does not. This is also a v0.2.0 candidate.
  • It does not touch disk. rescan is read-only and returns a value; the caller decides what to do about it.

isInstalled: checking agent availability

Before you call link(), you probably want to know whether the target agent is actually installed on the user's machine. isInstalled answers that in one batch call:

import { isInstalled } from 'agent-mcp-manager'

const installed = await isInstalled({
  agents: ['cursor', 'claude-code', 'gemini', 'vscode'],
})

if (installed.cursor) {
  await link(workspaceDir, { server, agent: 'cursor' })
}

Return shape: Partial<Record<AgentId, boolean>>. Only the agents you asked about appear as keys. Duplicates in the input array collapse.

What "installed" means here

In system scope, an agent is installed if any one of three signals fires:

  1. One of the catalog's installCheckPaths for the agent exists on disk. These are the agent's install fingerprints (/Applications/Cursor.app, ~/.opencode/, ~/.local/share/opencode/, ...).
  2. The resolved config file already exists on disk (~/.cursor/mcp.json, ~/Library/Application Support/Claude/claude_desktop_config.json, ...).
  3. The parent directory of the resolved config file exists.

In project scope, only signals (2) and (3) apply against the resolved projectRoot config path; installCheckPaths is a system-installation concept.

The three-signal system exists because some agents create their global config file only when the user first interacts with MCP. OpenCode's opencode.json and Codex's config.toml are user-created; a freshly installed OpenCode has no config file OR parent yet, but its data directories (~/.opencode/, ~/.local/share/opencode/) exist. Without the installCheckPaths signal, isInstalled would report false for real fresh installs. Signals (2) and (3) still catch agents whose parent directory is created at install time (Cursor's ~/.cursor/).

This is the same union-of-signals link() uses to gate AgentNotInstalledError. So isInstalled is exactly the precheck for link: if installed[agent] === false, link({server, agent}) will throw.

When to call it

  • Gating a UI dropdown: show only installed agents in a "Choose an agent" list.
  • Filtering a batch before linking: skip missing agents instead of catching per-call throws.
  • Background health check: on app startup, warn the user if agents they had linked previously are no longer available.
  • Predicting failures: any time you want to avoid catching AgentNotInstalledError in flow control.

Worked patterns

Show only installed agents in a UI dropdown.

const supported = listSupportedAgents()
const installed = await isInstalled({ agents: supported })
const shown = supported.filter((a) => installed[a] === true)
// Render `shown` in your UI.

Precheck a batch before linking.

const targets = ['cursor', 'claude-code', 'gemini']
const installed = await isInstalled({ agents: targets })
for (const agent of targets) {
  if (installed[agent]) {
    await link(workspaceDir, { server, agent })
  } else {
    showWarning(`${agent} is not installed; skipping.`)
  }
}

Predict what link will throw. Because both use the same signal:

const installed = await isInstalled({ agents: ['cursor'] })
// installed.cursor === false  =>  link({server, agent: 'cursor'}) will throw AgentNotInstalledError.

isInstalled vs detectInstalledAgents

Both use installCheckPaths, but they answer subtly different questions.

  • isInstalled is the precheck for link(). Returns true when ANY of the three signals fires (install fingerprint, config file exists, or config parent exists), so installed[agent] === true is a hard promise that link({server, agent}) will not throw AgentNotInstalledError.
  • detectInstalledAgents is the legacy per-agent metadata verb from the v0.0.3 line. Returns an array of { id, displayName, configPath, installed } where installed is true iff installCheckPaths fires. It does not consult the resolved config-path signals; a Cursor with ~/.cursor/mcp.json but no /Applications/Cursor.app (e.g., installed to a non-standard location) can read as installed: false here while isInstalled reports true.

Use isInstalled for gate-consistent precheck. Use detectInstalledAgents for the per-agent info payload (config path + display name + install fingerprint) when populating a UI table.

AgentNotInstalledError

The typed error link() throws when the install gate fails. Fields:

class AgentNotInstalledError extends McpManagerError {
  agent: AgentId
  configPath: string  // the exact path we checked
  parentDir: string   // its parent, also missing
}

Handle it around your link() call, or use isInstalled beforehand to avoid the throw entirely.

Migration from v0.0.3

If you're on v0.0.3 with createMcpManager, you have three paths:

  1. Migrate to the functional API (recommended). This section gives you the mechanical translations.
  2. Pin v0.0.3 with "agent-mcp-manager": "^0.0.3" in your package.json. The v0.0.3 line is unmaintained but functional; it stays on npm.
  3. Wait. No compat shim is planned. v0.2.0+ builds on the functional surface.

What changed and why

The v0.0.3 createMcpManager() returned an object with a private manifest reference that mutated across method calls. This produced a real bug (#63): the "disconnect one agent" flow required a three-line dance (unlink + listLinks + conditional remove), and any race or logic bug in the caller could silently orphan other agents' link records.

Under v0.1.0:

  • No mutable in-memory manifest. Every verb reads the manifest from disk at call time, computes a plan, applies it.
  • No pre-registration step. The v0.0.3 flow was add(name, spec) then link(name, agent). That two-step surface let the two calls drift out of sync, allowed manifest ghosts, and made concurrent adds silently clobber each other. v0.1.0 collapses this into one link({server, agent}) call. The server is caller-owned data.
  • Every operation composes. readState → plan* → applyPlan. You can inspect the plan before writing.
  • disconnect() is one primitive. It reads state, unlinks one agent, drops the manifest entry only if no other agents remain linked. Never touches other agents' config files. The #63 bug is structurally impossible.
  • The workspace manifest schema on disk is unchanged. If you have a manifest.json written by v0.0.3, v0.1.0 reads it without migration.

Migration table

| v0.0.3 (class API) | v0.1.0 (functional API) | |---|---| | const mgr = createMcpManager({ workspaceDir }) | const mgr = bind(workspaceDir) (optional; the raw verbs also work) | | await mgr.add({ name, spec })await mgr.link({ serverName: name, agent }) | const server = { name, spec }await link(workspaceDir, { server, agent }) | | await mgr.link({ serverName, agent, configPath }) | await link(workspaceDir, { server, agent, configPath }) | | await mgr.link({ serverName, agent, allowOverwrite: true }) | await link(workspaceDir, { server, agent, allowOverwrite: true }) | | await mgr.unlink({ serverName, agent }) | await unlink(workspaceDir, { serverName, agent }) | | await mgr.remove({ serverName }) | await remove(workspaceDir, { serverName }) | | await mgr.remove({ serverName, unlinkFirst: false }) | await remove(workspaceDir, { serverName, unlinkFirst: false }) | | await mgr.listServers() | await list(workspaceDir) | | await mgr.listLinks() | await listLinks(workspaceDir) | | await mgr.listLinks({ agents, serverNames }) | await listLinks(workspaceDir, { agents, serverNames }) | | await mgr.rescan() | await rescan(workspaceDir) (see the rescan section for report changes) |

No more addServer. v0.0.3 required two calls to link a server for the first time: mgr.add(...) then mgr.link(...). v0.1.0 does both in one link({server, ...}). Just build the server value inline and hand it to link. If you'd been storing the result of mgr.add() to reuse in later mgr.link calls, replace that with a caller-owned const server = {name, spec} variable.

The disconnect pattern (the #63 fix)

If your v0.0.3 code has this pattern:

// v0.0.3 (buggy: can orphan other agents' links)
await mgr.unlink({ serverName, agent })
const links = await mgr.listLinks({ serverNames: [serverName] })
if (links.length === 0) {
  await mgr.remove({ serverName })
}

Replace it with a single call:

// v0.1.0 (structural fix)
await disconnect(workspaceDir, {
  serverName,
  agent,
  removeIfLast: true,  // default is true; can pass false to keep the entry
})

The v0.1.0 disconnect reads the manifest once, computes the post-unlink links map, and only drops the manifest entry when that map is empty. It never touches any other agent's config file. If two callers race, each reads a fresh manifest at call time; the last writer wins on the manifest, and neither ever touches an unrelated agent's config.

Manager options

v0.0.3 accepted per-manager configuration through McpManagerOptions. Those knobs are now per-call:

| v0.0.3 McpManagerOptions field | v0.1.0 equivalent | |---|---| | workspaceDir | The first positional argument to every verb (or bind(workspaceDir) for sugar) | | scope | Pass scope: 'system' \| 'project' per call | | projectRoot | Pass projectRoot: string per call (required when scope: 'project') | | agentConfigPaths | Pass configPath: string per call to override the resolved path for that call |

Rationale: v0.0.3's per-manager agentConfigPaths map applied to every method call, which made mixed-scope operations awkward. v0.1.0's per-call configPath is one field with the same effect and no hidden state.

Return shape diffs

  • listServers → list. v0.0.3 returned InstalledServer[]; v0.1.0 list(workspaceDir) returns ManifestServerEntry[]. Same fields ({name, spec, addedAt, links}), different type name. Existing consumer code that reads .name, .spec, .addedAt, .links needs no change.
  • listLinks. v0.0.3 returned McpServerLink[] with optional drifted / broken / unmanaged flags. v0.1.0 listLinks(workspaceDir) returns ListedLink[] with just {serverName, agent, configPath}. Drift flags moved to rescan(), which now returns a dedicated {verified, drifted, missing} report.
  • rescan. v0.0.3 RescanResult had four buckets: verified / drifted / broken / unmanaged. v0.1.0 RescanReport has three: verified / drifted / missing. The v0.0.3 broken bucket is now folded into missing (a manifest link with no on-disk entry). v0.0.3's unmanaged bucket (on-disk entries with no manifest record) is not currently scanned; that's a v0.2.0 candidate.

New capabilities in v0.1.0

Available today; no equivalent in v0.0.3:

  • Dry-run. Import from agent-mcp-manager/lowlevel and call planLink / planDisconnect / etc. without applying. Inspect plan.ops (the exact file writes) and plan.nextManifest (the manifest snapshot) before deciding to apply. Example under "Dry-run and batching" below.
  • Batching. Run multiple planner calls against a single State snapshot, concatenate the ops, and apply them all in one pass with applyPlan.
  • Install-status detection. isInstalled({agents}) batch-checks whether each agent is available on the current machine, and link() throws AgentNotInstalledError (instead of silently creating a ghost config) when the target isn't installed. See the isInstalled section.
  • 16 additional clients. cline, opencode, goose, kiro, windsurf, witsy, roocode, enconvo, boltai, amazon-bedrock, amazonq, tome, librechat, antigravity, trae, vscode-insiders.

Known behavioral differences

  • One-step link. v0.0.3 required mgr.add() before mgr.link(). v0.1.0 link({server, agent}) does both. The manifest server entry is upserted as a side-effect of the link.
  • Last-write-wins on the manifest spec. If you call link({server: A, agent: 'cursor'}) and later link({server: A', agent: 'gemini'}) where A.name === A'.name but the specs differ, the manifest's spec updates to A'. Cursor's config still holds A on disk (stale) until you re-link cursor. Use rescan() to detect this class of drift.
  • Trimmed names. link({server: {name: ' gh ', ...}}) persists the trimmed key 'gh'. v0.0.3 persisted the untrimmed value.
  • Idempotent re-links skip the config write. link() no longer touches mtime when the resulting content is identical. IDE file-watchers (Cursor, VS Code) no longer reload on idempotent re-runs.
  • exists: true for empty files. readState from /lowlevel returns AgentFileState.exists = true for existing empty files; v0.0.3 conflated existence with non-emptiness.
  • unlink uses the manifest-recorded configPath. If you link({ configPath: X }) in v0.0.3 and later unlink() without a configPath, v0.0.3 rewrote the OS-default path (potentially skipping the file that had the entry). v0.1.0 looks up the recorded path from the manifest first.

If you consumed McpManager as a type

v0.0.3 exported the McpManager interface for consumers to store the manager instance in class fields or React refs. v0.1.0 has no equivalent because the verbs are free functions. Two options:

// Option A: store the workspaceDir, call the free functions on demand.
class MyApp {
  private workspaceDir = '~/.myapp/mcp'
  async link(server: McpServer, agent: AgentId) {
    await link(this.workspaceDir, { server, agent })
  }
}

// Option B: store a BoundApi.
import { bind, type BoundApi, type McpServer, type AgentId } from 'agent-mcp-manager'
class MyApp {
  private mcp: BoundApi = bind('~/.myapp/mcp')
  async link(server: McpServer, agent: AgentId) {
    await this.mcp.link({ server, agent })
  }
}

Both are stateless: every method call re-reads the manifest from disk.

Dry-run and batching

Import from agent-mcp-manager/lowlevel for the pure planner primitives plus readState and applyPlan. Every verb returns a Plan you can inspect before touching disk.

import {
  readState,
  planLink,
  planDisconnect,
  applyPlan,
} from 'agent-mcp-manager/lowlevel'

const state = await readState(workspaceDir, ['cursor', 'gemini'])

// Compute both plans against the SAME state snapshot.
const linkPlan = planLink(
  state,
  { server: { name: 'gh', spec: { transport: 'stdio', command: 'gh-mcp' } }, agent: 'cursor' },
  new Date().toISOString(),
)
const disconnectPlan = planDisconnect(state, { serverName: 'old', agent: 'gemini' })

// Inspect before writing.
console.log('link ops:', linkPlan.ops)
console.log('disconnect ops:', disconnectPlan.ops)

// Apply when ready. Each plan is independent; combined ops write once.
await applyPlan({
  ops: [...linkPlan.ops, ...disconnectPlan.ops],
  nextManifest: disconnectPlan.nextManifest,
})

FsOp is a discriminated union:

type FsOp =
  | { kind: 'writeFile'; path: string; content: string; ensureDir?: boolean }
  | { kind: 'removeFile'; path: string }

Every write goes through atomic <file>.tmp + rename.

Supported clients

23 clients ship in v0.1.0 with hand-authored per-client shape declarations. See src/_catalog/client-configs.ts for the source of truth, including each entry's citation URLs.

| Established | Well-documented | Additional | |---|---|---| | claude-desktop | cline | amazon-bedrock | | claude-code | opencode | amazonq | | cursor | goose | antigravity | | vscode | kiro | boltai | | vscode-insiders | windsurf | enconvo | | gemini | witsy | librechat | | codex | roocode | tome | | zed | | trae |

Every catalog entry has:

  • A first-party MCP docs URL as its primary source.
  • A Smithery cross-check URL (design reference, AGPL-3.0; see THIRD_PARTY_NOTICES.md).
  • An ISO verified date. The catalog validator rejects any entry more than 12 months stale.

Transport support and safety guarantees

Each client declares which transports it accepts (stdio / sse / http). Writing an entry with an unsupported transport throws UnsupportedTransportError before any file write. Concrete examples:

  • Claude Desktop is stdio-only; an HTTP spec throws.
  • Claude Code project scope (.mcp.json) is stdio-only; system scope (~/.claude.json) accepts all three.
  • Codex accepts stdio and streamable HTTP but not SSE.

On disk:

  • Atomic writes. Every edit goes through <file>.tmp + rename. A crashed process never leaves a half-written config file.
  • Foreign-entry protection. link throws ForeignEntryError when an on-disk entry under the target name was not put there by the manifest. Pass allowOverwrite: true to take ownership.
  • Structural protection against orphaning. disconnect computes its ops from the manifest's links map. Under this shape, disconnecting one agent from a server that four others share never touches the four others' config files. The v0.0.3 class API had a bug (issue #63) where remove blew away shared manifest entries. That bug is structurally impossible under the FP API.
  • Idempotent writes. Re-linking a server that's already correctly present skips the file write entirely. Editors watching the config file do not reload.
  • Install-status gate. link() refuses to create a ghost config directory for an agent whose config path is under a non-existent parent. The typed AgentNotInstalledError carries the agent id, the config path we checked, and the parent directory so consumers can surface an actionable prompt.

Types

Everything you need is at the package root or under agent-mcp-manager/lowlevel. Import paths:

import type {
  McpServer,              // { name, spec }
  McpServerSpec,          // stdio | sse | http
  McpStdioSpec,
  McpHttpSpec,
  McpSseSpec,
  AgentId,                // union of 23 client ids
  AgentScope,             // 'system' | 'project'
  ServerManifest,         // on-disk schema
  ManifestServerEntry,
  ManifestLinkEntry,
  LinkPlanSummary,
  UnlinkPlanSummary,
  DisconnectPlanSummary,
  RemovePlanSummary,
  RescanReport,
  IsInstalledInput,
  IsInstalledResult,      // Partial<Record<AgentId, boolean>>
  BoundApi,
} from 'agent-mcp-manager'

import { AgentNotInstalledError } from 'agent-mcp-manager'  // thrown by link()

import type {
  State, Plan, FsOp,
  LinkInput,
  DisconnectInput,
  // ... every planner input and result summary
} from 'agent-mcp-manager/lowlevel'

Contributing

Author under MIT. Every catalog entry needs a first-party docs URL and an ISO verified date; the validator enforces both. Test files live next to the source folders they exercise.

License: MIT. See THIRD_PARTY_NOTICES.md for research references.