Core API
@discord-mcp/core is the programmatic package behind the CLI server. Import
only from the package root; internal file paths are not exported.
pnpm add @discord-mcp/core @discordjs/rest @modelcontextprotocol/sdk @sapphire/pieces pino zodPublic surface by job
Section titled “Public surface by job”| Job | Root exports |
|---|---|
| Server | buildServer, BuildServerDeps, BuildServerResult, VERSION |
| Configuration | loadConfig, Config |
| Tools and results | defineTool, ToolDefinition, Tool, ToolRunContext, dualResult |
| IDs and pagination | Snowflake, ApplicationId, ChannelId, EmojiId, GuildId, MessageId, RoleId, UserId, WebhookId, encodeCursor, decodeCursor |
| Errors | DiscordError families, formatErrorForUser, FormatErrorContext |
| Resilience | buildPolicy, wrapRestWithResilience, classifyDiscordError |
| Middleware | compose, telemetryMiddleware, validateMiddleware, preconditionMiddleware, auditMiddleware |
| Gateway notifications | createGatewayClient, GatewayClient, SubscriptionRegistry |
| Audit | createAuditSink, sink classes, redactArgs |
| Safety helpers | wrapMessages, wrapUntrusted, redactRoute |
The authoritative export list is
packages/mcp-core/src/index.ts.
Define a tool
Section titled “Define a tool”defineTool accepts bare Zod field maps. A handler should return an MCP
CallToolResult; dualResult creates the text plus structuredContent shape
used throughout the project.
import { ChannelId, defineTool, dualResult } from '@discord-mcp/core';import { z } from 'zod';
export default defineTool({ name: 'channel_describe', description: '**Purpose**: Return a short channel description.', category: 'channels', inputSchema: { channel_id: ChannelId, }, outputSchema: { summary: z.string(), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, idempotent: true, async handler({ channel_id }, ctx) { const response = await fetch(`https://example.internal/channels/${channel_id}`, { signal: ctx.signal, }); const channel = (await response.json()) as { name: string }; const summary = `#${channel.name}`; return dualResult({ text: summary, data: { summary } }); },});Important distinctions:
annotations.readOnlyHintdescribes semantics to MCP clients.annotations.idempotentHintsays repeated calls have the same intended effect.def.idempotentis runtime metadata used by telemetry and the current audit middleware. It is not synonymous with read-only: an idempotent PUT can write, and the current audit layer skips all tools markedidempotent: true.ToolRunContextguaranteessignal. The built-in pipeline path augments its internal context with invocation support, but custom handlers should not rely on an undocumentedctx.invoke.
In tests, defineTool validates successful structuredContent against the
declared output schema. Production does not turn a schema mismatch into a new
runtime failure.
Build an MCP server
Section titled “Build an MCP server”import { buildPolicy, buildServer, createLogger, loadConfig, wrapRestWithResilience,} from '@discord-mcp/core';import { REST } from '@discordjs/rest';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const config = loadConfig();const logger = createLogger(config);const token = config.DISCORD_TOKEN.startsWith('Bot ') ? config.DISCORD_TOKEN.slice(4) : config.DISCORD_TOKEN;
const baseRest = new REST({ version: '10', retries: 0 }).setToken(token);const rest = wrapRestWithResilience(baseRest, buildPolicy(config, logger), { circuitHalfOpenAfterMs: config.MCP_CIRCUIT_HALF_OPEN_AFTER_MS,});
const { server, auditSink } = await buildServer({ rest, logger, config });await server.connect(new StdioServerTransport());
async function shutdown() { await server.close(); await auditSink.shutdown?.();}buildServer returns the unconnected SDK server, registered tool and
precondition names, a resource-notification function, the subscription
registry, and the audit sink. The normal CLI transport also starts optional
OpenTelemetry and Gateway services and shuts them down in order.
new REST({ retries: 0 }) disables discord.js retries for server errors and
timeouts. It does not disable the library’s route/global rate-limit queue.
Configuration contract
Section titled “Configuration contract”function loadConfig(env: NodeJS.ProcessEnv = process.env): ConfigThe parser validates DISCORD_TOKEN, coerces supported booleans and numbers,
and fills declared defaults. Fields whose schema is optional remain optional;
for example DISCORD_DEFAULT_GUILD_ID, MCP_CATEGORIES,
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, and
MCP_AUDIT_FILE can be absent.
Invalid input throws a plain Error containing a line for each Zod issue.
ConfigSchema itself is not exported.
Error results
Section titled “Error results”All custom errors extend DiscordError. Current stable codes include:
| Family | Codes |
|---|---|
| Client | DISCORD_PERMISSION_DENIED, DISCORD_RATE_LIMITED, DISCORD_NOT_FOUND, VALIDATION_FAILED, DISCORD_AUTH_INVALID, DISCORD_CLOUDFLARE_BLOCKED, SCOPE_REJECTED, GUILD_NOT_ALLOWED, DRY_RUN_PREVIEW, CANCELLED |
| Server | DISCORD_SERVER_ERROR, CIRCUIT_OPEN, BULKHEAD_FULL, INTERNAL_ERROR |
formatErrorForUser guarantees code, retriable, and category in
structuredContent; diagnosis and recovery prose is in content[0].text.
import { DiscordPermissionError, formatErrorForUser } from '@discord-mcp/core';
const error = new DiscordPermissionError( ['SEND_MESSAGES'], ['VIEW_CHANNEL'], 'channels/123456789012345678',);
const result = formatErrorForUser(error, { toolName: 'messages_send', transport: 'stdio',});See Error handling for the wire shape and retry guidance.
Middleware and registration
Section titled “Middleware and registration”The built-in server invokes six stages in this order:
telemetry → default guild → validation → category gate → preconditions → auditbuildServer registers the shipped tools explicitly. The runtime does not scan
the filesystem for new tool files; adding a built-in tool requires exporting its
class and adding it to server registration.