Skip to content

Authoring an adapter

A migration adapter helps users switch from another Discord MCP server onto discord-mcp. If your favourite Discord MCP project becomes unmaintained, an adapter for it is one of the highest-impact contributions you can make to this codebase: small (~200 LOC + tests + a docs page), self-contained (no runtime coupling to other adapters), and immediately useful to anyone left on the abandoned project.

  • Help users migrate quickly. A working adapter turns a multi-day prompt-rewriting project into a one-command discord-mcp migrate report.
  • Document the source. Even a partial NAME_MAP is the most useful documentation a community member can produce — it captures which tools matter, which fold onto which destination calls, and where the rough edges are.
  • Low surface area. An adapter is one file under packages/mcp-server/src/lib/migrate-adapters/, one test file, one fixture under packages/mcp-server/test-fixtures/, and one MDX page on this site.

Every adapter implements the same interface, exported from packages/mcp-server/src/lib/migrate-adapters/types.ts:

export interface MigrationSource {
readonly id: string;
readonly description: string;
readonly homepage?: string;
readonly languages: readonly ('typescript' | 'go' | 'python' | 'mixed')[];
readonly toolCountEstimate?: number;
detect(rootPath: string): Promise<boolean>;
migrate(rootPath: string): Promise<MigrationResult>;
}

Required fields:

  • id — kebab-case identifier passed via --from <id>. Must be unique across all adapters.
  • description — single-line human-readable description shown by migrate --list.
  • languages — what the source project is written in. Helps users understand at a glance whether they need a Go clone, a TS clone, etc.
  • detect(rootPath) — fast yes/no answer to “is this filesystem the shape I understand?” Must NOT throw — wrap fs ops in try/catch.
  • migrate(rootPath) — walk the source tree and return a MigrationResult listing mapped / unmapped / manual-review tools plus warnings.

Optional fields:

  • homepage — link to the upstream repo, surfaced by --list.
  • toolCountEstimate — best-effort tool count for --list; helps users gauge migration scope.

See the four shipping adapters for working examples: hubdustry-go-mcp, pasympa, quadslab, discord-ops.

detect() must answer true for a real clone of your source project and false for absolutely everything else — including the other shipping adapters’ fixtures.

A single signal is too weak. Every shipping adapter combines AT LEAST two independent signals:

  • Signal 1 — package metadata: package.json name field matches a pattern (literal name OR /regex/i covering forks).
  • Signal 2 — content match: at least one source file under src/ contains a syntactic pattern unique to this project. Patterns we use today:
    • PaSympa: 'discord_<x>' literals (the discord_ prefix is the fingerprint).
    • quadslab: <x>Tools array exports + execute<X>Tool function exports in the same file.
    • discord-ops: defineTool( calls + a category: field literal.
    • hubdustry-go-mcp: Go file extension + mcp.NewTool( regex (Go is wholly disjoint from TypeScript adapters).

Disjointness is mandatory. Your detect function MUST return false on all four existing adapter fixtures. Add tests in both directions (see Cross-detection below).

detect() is called for every adapter on every migrate invocation. Keep it cheap:

  • Wrap every fs op in try { ... } catch { return false; } — never let an IO failure bubble up.
  • Bail early on the cheapest signal first (typically package.json existence + name pattern).
  • Cap directory walks at the smallest scope you can — src/tools/ first, fall back to src/ only if missing.
  • Return true on first match — don’t keep walking.

migrate() walks the source tree and extracts every tool name it can find. Two patterns work:

Pattern A — generic regex over a known prefix

Section titled “Pattern A — generic regex over a known prefix”

Use when the source server names every tool with a unique prefix (discord_<x> for PaSympa). The regex /['"]discord_[a-z_0-9]+['"]/g matches every literal in the codebase. Risk: false positives on string literals that happen to match the pattern. Acceptable when the prefix is distinctive.

Use when the source server uses generic snake_case (send_message, list_channels) that a regex would over-match. Maintain a KNOWN_<SOURCE>_TOOLS: ReadonlySet<string> of every tool name you’ve catalogued. Match [a-z_]+ literals and intersect with the set. quadslab and discord-ops both use this pattern.

Trade-off: pattern B is high-precision but requires the catalog to be updated for every new source release. Document the cutoff date in the file’s research-note JSDoc so future maintainers know when it was last checked.

Best-effort: missing tools is OK, false positives are NOT

Section titled “Best-effort: missing tools is OK, false positives are NOT”

If your regex misses a multi-line defineTool({...}) call or a concatenated tool name, that’s acceptable — the user sees a slightly shorter unmapped list and notices the gap manually. False positives are worse: they pollute the report with non-existent tools and erode trust.

Maps source tool name → discord-mcp <category>_<verb> with confidence.

const NAME_MAP: Record<
string,
{ mapped: string; confidence: 'high' | 'medium' | 'low'; notes?: string }
> = {
send_message: { mapped: 'messages_send', confidence: 'high' },
send_embed: {
mapped: 'messages_send',
confidence: 'medium',
notes: 'pass embeds[] in messages_send payload',
},
notify_owners: {
mapped: 'messages_send',
confidence: 'low',
notes: 'first call users_create_dm to get the DM channel ID',
},
};
  • high — name and argument shape are a 1:1 match. Caller can swap names without thinking.
  • medium — name maps cleanly but the caller may need to massage args (e.g. embed-specific helper folds onto the general messages_send). Always include notes.
  • low — best-guess mapping; user MUST verify. Always include notes describing the caveat.

A medium- or low-confidence entry without notes is a bug. The note must explain what the caller has to change about argument shape.

If a source tool has no discord-mcp equivalent, omit it from NAME_MAP. migrate() will surface it under unmappedTools. Document the omissions in your file’s research-note JSDoc — explain why each one is unmapped (no equivalent / different surface / architectural mismatch). Future maintainers will read your notes when discord-mcp adds a new tool that closes the gap.

Every adapter ships a synthetic fixture under packages/mcp-server/test-fixtures/<adapter-id>/. Layout mirrors the upstream’s minimal surface:

test-fixtures/<adapter-id>/
package.json # FIXTURE: synthetic <name>-shaped package.json
src/
tools/
<example>.ts # FIXTURE: synthetic <name>-style code

Every fixture file MUST start with the comment:

// FIXTURE: synthetic <name>-style code for adapter testing — not real code

This prevents future grep-greppers from mistaking the fixture for live code. Same applies to package.json (use the description field).

Your fixture must exercise:

  • Happy path — at least one tool that maps to high, one to medium, one to low.
  • Unmapped path — at least one tool intentionally omitted from NAME_MAP, so the test asserts it surfaces in unmappedTools.
  • Detection — the fixture must satisfy your detect() (positive case).

Don’t copy real upstream code. Synthetic snippets are smaller, faster, and free of license complications.

This is the rule that prevents an adapter from claiming a tree that belongs to someone else.

Your adapter’s detect() MUST return false on every existing adapter’s fixture. Conversely, every existing adapter’s detect() MUST return false on your fixture.

Tests look like:

describe('myAdapter detect()', () => {
it('returns false on hubdustry fixture', async () => {
expect(
await myAdapter.detect('packages/mcp-server/test-fixtures/hubdustry-go-mcp'),
).toBe(false);
});
it('returns false on pasympa fixture', async () => {
expect(
await myAdapter.detect('packages/mcp-server/test-fixtures/pasympa'),
).toBe(false);
});
// ... and so on for every shipping adapter.
});
describe('existing adapters reject myAdapter fixture', () => {
for (const adapter of [hubdustryGoMcpAdapter, pasympaAdapter, /* ... */]) {
it(`${adapter.id} returns false on my fixture`, async () => {
expect(
await adapter.detect('packages/mcp-server/test-fixtures/my-adapter'),
).toBe(false);
});
}
});

CI runs both directions; failures here block the merge.

  1. Fork the repo and create a branch.

    Terminal window
    gh repo fork cappylab/discord-mcp --clone
    cd discord-mcp
    git checkout -b feature/adapter-myname
  2. Add the adapter file.

    packages/mcp-server/src/lib/migrate-adapters/<id>.ts with the research note JSDoc, NAME_MAP, detect(), migrate(), and the singleton export. Use one of the four shipping adapters as a starting template.

  3. Add the test fixture.

    packages/mcp-server/test-fixtures/<id>/ with package.json (synthetic metadata) and src/tools/ (synthetic tool definitions). Every file gets the FIXTURE header comment.

  4. Add tests.

    packages/mcp-server/src/lib/migrate-adapters/<id>.test.ts covering:

    • detect() positive on your fixture.
    • detect() negative on every other adapter’s fixture (cross-detection).
    • migrate() produces expected mapped / unmapped counts on your fixture.
    • At least one medium and one low confidence assertion.
  5. Register in ALL_ADAPTERS.

    Edit packages/mcp-server/src/lib/migrate-adapters/index.ts and add your adapter to the array. Order doesn’t matter functionally, but keep it alphabetical-ish for readability.

  6. Add the docs page.

    site/src/content/docs/migrate/<id>.mdx with sidebar order matching your registration. Use one of the existing per-adapter pages as a template.

  7. Run the verification suite locally.

    Terminal window
    pnpm exec biome check --write .
    pnpm exec tsc --noEmit -p packages/mcp-server/tsconfig.json
    pnpm test
    pnpm --filter site build

    All four green is the bar.

  8. Open a PR.

    Title: feat(migrate): adapter for <source name>. Body includes a link to the upstream repo, the cutoff commit you researched against, and a summary of mapped / unmapped tool counts.