Skip to content

Write an MCP server

When you add an MCP server to Speedwave, you’re writing an integration worker: an isolated process that exposes one service (GitLab, Slack, the OS) as MCP tools. Claude never talks to your worker directly. Requests pass through the Tool Gateway, the only MCP server Claude sees. It holds no tokens and routes each call over Docker internal DNS to the worker that owns the credentials, at http://mcp-{name}:{port} (or http://host.docker.internal:{port} for the OS worker). Shipping the worker to other installs means packaging it as a plugin; see Write a plugin.

Keep mcp-servers/gitlab open as you read this: it’s the reference implementation for everything below.

Don’t reimplement boot, validation, error formatting, or result helpers. @speedwave/mcp-shared already has them: bootWorker, the Tool type, the withResultValidation and withClientValidation wrappers, token loading, and the sanitize log redactor.

Call bootWorker() from main(). Here’s the whole GitLab entrypoint:

mcp-servers/gitlab/src/index.ts
import { bootWorker, ts, makeStandardHealthCheck } from '@speedwave/mcp-shared';
import { initializeGitLabClient, type GitLabClient } from './client.js';
import { createToolDefinitions } from './tools/index.js';
bootWorker<GitLabClient>({
serverName: 'mcp-gitlab',
version: '1.0.0',
displayName: 'GitLab',
authTokenEnv: 'MCP_GITLAB_AUTH_TOKEN',
host: '0.0.0.0',
initClient: initializeGitLabClient,
makeTools: (client) => createToolDefinitions(client),
makeHealthCheck: (client) => /* ... */,
}).catch((error) => {
console.error(`${ts()} Fatal error:`, error);
process.exit(1);
});

You get a PORT read automatically, retries on initClient with backoff, and an exit on a missing authTokenEnv. onNotConfigured decides what happens if the client comes back unconfigured: 'warn' (default: logs and starts anyway) or 'fail' (exits). Skip initClient for a credential-free worker like office.

Mount only your service’s own credentials at /tokens, read-only. Resolve the path with tokensDir() and read a file with loadTokenFile(name). The GitLab client reads two files:

  • Directory/tokens/
    • token the GitLab access token
    • host_url optional; overrides GITLAB_URL, defaults to https://gitlab.com

If loading fails, catch it and return null from your init function instead of crashing: the server still starts, and every tool call returns a “not configured” error. For an OAuth-based service, use authedRequest, authedSdkCall, and RefreshLock instead of a static token: they refresh on an auth failure and serialize concurrent refreshes.

A tool is an object on the Tool interface. Here’s listBranches from the GitLab worker:

mcp-servers/gitlab/src/tools/branch-tools.ts
const listBranchesTool: Tool = {
name: 'listBranches',
description: 'List branches in a project',
annotations: READ_ONLY_ANNOTATIONS,
_meta: { deferLoading: true },
keywords: ['gitlab', 'branches', 'list', 'git', 'refs'],
example: 'const branches = await gitlab.listBranches({ project_id: "speedwave/core" })',
inputSchema: {
type: 'object',
properties: {
project_id: { type: ['string', 'number'], description: 'Project ID or path' },
search: { type: 'string', description: 'Search by branch name' },
limit: { type: 'number', description: 'Max results (default 20)' },
},
required: ['project_id'],
},
outputSchema: { /* JSON Schema for the result */ },
inputExamples: [
{ description: 'List all branches', input: { project_id: 'my-group/my-project' } },
],
};

Give it a name matching ^[a-zA-Z0-9_-]+$, 1 to 99 characters, and keywords so on-demand discovery finds it. Set _meta.deferLoading to true for a tool that loads on demand, false to keep it always loaded. Pick the annotation constant by what the tool does to the service:

ConstantreadOnlyHintdestructiveHintExample
READ_ONLY_ANNOTATIONStruefalselistBranches, getBranch
WRITE_ANNOTATIONSfalsefalsecreateBranch
DESTRUCTIVE_ANNOTATIONSfalsetruedeleteBranch

Pair each tool with a handler as { tool, handler }, grouped into factories like createBranchTools(client), then collect every factory into your worker’s tool list.

If your handler takes a non-null client and throws on failure, use withClientValidation: it gates on a null client and maps the thrown error to a user-facing string, the way GitLab does it. If your handler already returns a { success, data?, error? } result, use withResultValidation instead, the way Slack does it. Either way, point unconfigured errors at notConfiguredMessage(service) and map your service’s status codes to plain-language strings.

Run everything you log through sanitize() so secrets never reach a log line.

Call your tool factory with a mock or null client, then resolve each handler by name and invoke it directly. Cover:

  • Metadata: name, description, annotations, keywords, schemas.
  • A successful execute path against a mocked client method.
  • Parameter validation for missing or falsy inputs.
  • Error handling for thrown errors, and the unconfigured-client path.

Run make test-mcp for the suite and make coverage-mcp for coverage thresholds. mcp-servers/gitlab/src/tools/ has reference tests to copy from.