# 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](/docs/under-the-hood/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](/docs/plugins/writing-a-plugin/).

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

## Import the shared package

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.

## Boot your worker

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

```typescript title="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 your credentials

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

- /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.

## Declare a tool

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

```typescript title="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:

| Constant | `readOnlyHint` | `destructiveHint` | Example |
| --- | --- | --- | --- |
| `READ_ONLY_ANNOTATIONS` | `true` | `false` | `listBranches`, `getBranch` |
| `WRITE_ANNOTATIONS` | `false` | `false` | `createBranch` |
| `DESTRUCTIVE_ANNOTATIONS` | `false` | `true` | `deleteBranch` |

## Wrap your handler

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.

## Test your tools

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.