Advanced guide

Build Your Own MCP Server for OpenClaw

This path is for people who have confirmed that existing MCPs are not stable enough for the workflow they need. For most common jobs, installing an existing MCP is still the better default. Build a custom MCP when you need fixed rules, private APIs, stable output, or narrower permissions.

Install first, or build your own?

Install existing MCP

  • Filesystem, Context7, GitHub, Playwright, Firecrawl
  • The need is common and maintenance belongs to the upstream project
  • You want speed to value more than owning a new codebase

Build custom MCP

  • Private API, internal playbook, fixed JSON output, narrow-permission workflows
  • The workflow is stable, but prompts keep drifting or growing
  • You want to lock down allowed reads, errors, and output shape

Custom MCP design canvas

Write the tool contract before coding. This template locks the input, output, allowed resources, and failure boundary.

Custom MCP design canvas markdown
| Field | Decision |
| --- | --- |
| Tool name | lookup_playbook_rule |
| User goal | Return one approved rule for a repeatable workflow |
| Inputs | topic: string |
| Output | { title: string, rule: string, source: string } |
| Allowed resources | ./playbooks/*.json only |
| Forbidden actions | No writes, no shell commands, no network |
| Timeout | 3 seconds |
| Error codes | NOT_FOUND, INVALID_INPUT, ACCESS_DENIED |
| Verification prompt | Ask for the rule for "release checklist" |

Minimal custom MCP server

This minimal TypeScript stdio server is small enough to learn from, but still keeps inputSchema, explicit errors, and a read-only boundary.

Minimal custom MCP server ts
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const DATA_FILE = join(__dirname, '../data/playbooks.json');
const REQUEST_TIMEOUT_MS = 3_000;

type PlaybookRule = {
  topic: string;
  title: string;
  rule: string;
  source: string;
};

const server = new McpServer({ name: 'custom-playbook-mcp', version: '1.0.0' });

async function readRules(): Promise<PlaybookRule[]> {
  // Allowlist: read only ./data/playbooks.json. Never accept user-controlled file paths.
  const raw = await readFile(DATA_FILE, 'utf8');
  return z.array(
    z.object({
      topic: z.string(),
      title: z.string(),
      rule: z.string(),
      source: z.string(),
    })
  ).parse(JSON.parse(raw));
}

function errorPayload(code: 'INVALID_INPUT' | 'NOT_FOUND' | 'ACCESS_DENIED', message: string) {
  return JSON.stringify({ error: code, message });
}

server.registerTool(
  'lookup_playbook_rule',
  {
    description: 'Return one approved rule from the local playbook.',
    inputSchema: z.object({ topic: z.string().min(1) }),
  },
  async ({ topic }) => {
    const normalizedTopic = topic.trim().toLowerCase();
    if (!normalizedTopic) {
      return {
        content: [{ type: 'text', text: errorPayload('INVALID_INPUT', 'topic must not be empty') }],
      };
    }

    const task = (async () => {
      const rules = await readRules();
      const match = rules.find((item) => item.topic.toLowerCase() === normalizedTopic);
      if (!match) {
        return {
          content: [{ type: 'text', text: errorPayload('NOT_FOUND', `No rule found for "${topic}"`) }],
        };
      }

      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify({
              title: match.title,
              rule: match.rule,
              source: match.source,
            }),
          },
        ],
      };
    })();

    return await Promise.race([
      task,
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error('ACCESS_DENIED: request timed out or touched a forbidden resource')), REQUEST_TIMEOUT_MS)
      ),
    ]);
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Create custom MCP project

These commands create the smallest local project worth wiring into OpenClaw over stdio.

Create custom MCP project bash
mkdir custom-playbook-mcp
cd custom-playbook-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D tsx typescript @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx src/server.ts"
mkdir src data

Run custom MCP server

During local development, this is the exact stdio command OpenClaw will launch. Prove it in the terminal before wiring config.

Run custom MCP server bash
npm run dev

Custom MCP playbook fixture

Start with a deterministic fixture so the protocol, fields, and failures are stable before touching real systems.

Custom MCP playbook fixture json
[
  {
    "topic": "release checklist",
    "title": "Release checklist",
    "rule": "Run tests, build, verify config, and request review before deploy.",
    "source": "playbooks.json"
  },
  {
    "topic": "incident handoff",
    "title": "Incident handoff",
    "rule": "Summarize impact, status, owner, next checkpoint, and customer risk.",
    "source": "playbooks.json"
  }
]

Custom MCP OpenClaw config

Use one direct config path for v1. Replace cwd with your absolute path and avoid mixing multiple launch strategies.

Custom MCP OpenClaw config json
{
  "mcpServers": {
    "custom-playbook": {
      "command": "npx",
      "args": ["tsx", "./src/server.ts"],
      "cwd": "/absolute/path/to/custom-playbook-mcp"
    }
  }
}

Verification checklist

  • Normal input: topic=release checklist returns title, rule, and source.
  • Empty input: return INVALID_INPUT instead of an empty object or crash.
  • Unknown topic: return NOT_FOUND.
  • Forbidden path: do not accept external paths or arbitrary filenames.
  • Timeout: fail explicitly after 3 seconds instead of hanging.
  • Output shape: keep a stable JSON string with only title, rule, and source.

Custom MCP verification prompt

Custom MCP verification prompt text
Use the custom-playbook MCP to look up the rule for "release checklist".
Return only the JSON fields title, rule, and source.

Production hardening

Keep schemas stable and versioned, with explicit error codes.
Stay read-only by default; avoid writes, shell calls, and network unless required.
Use a path allowlist instead of accepting user-provided paths.
Never log raw secrets, tokens, or customer data.
Set timeouts and observable failures for every tool call.
Add minimal tests for success, empty input, and unknown topic.

Common errors

server not showing

Symptom: OpenClaw never shows custom-playbook. Likely cause: cwd is not absolute, or tsx/dependencies are missing. Fix: run npm run dev in the project first, then verify cwd in config.

tool call failed

Symptom: the tool appears, but calls fail. Likely cause: playbooks.json does not match the expected shape, or an unhandled exception escapes. Fix: prove the fixture first, then map failures to explicit errors.

invalid JSON response

Symptom: the model receives unstable or mixed output. Likely cause: extra prose was returned or the shape drifted. Fix: return only JSON.stringify with title, rule, and source.

works locally but not in OpenClaw

Symptom: the terminal run works but OpenClaw fails. Likely cause: different working directory, environment variables, or launch command. Fix: align command, args, cwd, and inspect the real OpenClaw launch logs.

Need review on the custom MCP config, tool contract, or failure logs?

Back to setup guides