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.
| 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.
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.
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.
npm run dev Custom MCP playbook fixture
Start with a deterministic fixture so the protocol, fields, and failures are stable before touching real systems.
[
{
"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.
{
"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
Use the custom-playbook MCP to look up the rule for "release checklist".
Return only the JSON fields title, rule, and source. Production hardening
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?