MCP Server Builder skill for Codex

Tools an agent picks right the first time. Codex designs each tool as a contract first, with a decisive name, a description that separates it from neighbouring tools, typed and bounded inputs, compact structured output and accurate annotations, then implements it with the current TypeScript or Python MCP SDK. It tests through in-memory clients and the MCP Inspector, connects the server with codex mcp add, and fixes descriptions when real prompts pick the wrong tool.

  • Codex Mastery
  • $mcp-server-builder
  • 219 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mcp-server-builder

Installs to ~/.agents/skills/mcp-server-builder/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $mcp-server-builder, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Model Context Protocol servers with the official TypeScript SDK v2 (@modelcontextprotocol/server, registerTool with zod schemas, serveStdio, createMcpHandler) or Python SDK v2 (MCPServer, @mcp.tool, mcp.run), designing tools, resources and prompts an agent can use correctly, then tests them with in-memory clients and the MCP Inspector and connects them to Codex. Use when creating a new MCP server, wrapping an internal API or CLI as MCP tools, porting a server from SDK v1, or fixing a server whose tools Codex misuses or cannot call.

Use it when

  • Creating a new MCP server for an API, database or CLI
  • Migrating a server from MCP SDK v1 to v2
  • Agents misuse a server's tools or get oversized results
  • Adding tests to an MCP server that has none

Use something else when

How to check the result

In-memory client tests cover schema rejection, empty results, tool errors and truncation; then /mcp verbose in Codex lists the tools and three realistic prompts select the right tool with valid arguments.

The mistake it prevents

Printing logs to stdout from a stdio server, which corrupts the JSON-RPC stream and makes the host drop the connection.

~/.agents/skills/mcp-server-builder/SKILL.md

--- name: mcp-server-builder description: Builds Model Context Protocol servers with the official TypeScript SDK v2 (@modelcontextprotocol/server, registerTool with zod schemas, serveStdio,… license: MIT ---

A server is good when an agent that has never seen it picks the right tool from the names and descriptions alone, sends valid arguments on the first try, gets compact results it can act on, recovers from errors by reading them, and cannot do damage the user did not approve. Protocol plumbing is the easy part; tool design is the work.

Choose the shape first

QuestionDefaultEscape hatch
Transportstdio for a personal or per-machine tool launched by CodexStreamable HTTP when many users share one deployment or the data lives server-side
LanguageThe language of the system you wrapTypeScript when you need web-standard runtimes (Workers, Deno, Bun)
Tool granularityOne tool per user intent (“find_invoice”, “refund_invoice”)Thin 1:1 API wrappers only for small, stable APIs
Read vs writeSeparate tools for reads and writesNever one tool with a mode flag that sometimes deletes
PrimitivesTools for actions and queriesResources for read-only documents the client attaches; prompts for reusable templates

Check SDK versions before writing code: npm view @modelcontextprotocol/server version and pip index versions mcp. Both SDKs are on v2 (spec revision 2026-07-28). v1 code (@modelcontextprotocol/sdk, server.tool(), StdioServerTransport, FastMCP imports) needs the official migration guides.

Workflow

- [ ] 1. List the user intents and map each to one tool
- [ ] 2. Write schemas and descriptions before handlers
- [ ] 3. Implement handlers with bounded, structured output
- [ ] 4. Test in memory, then with the Inspector
- [ ] 5. Connect to Codex and run real prompts
- [ ] 6. Harden: auth, limits, annotations, logging

1-2. Tool contracts

For each tool write, in this order:

  • Name: verb_noun, unique across the servers a user is likely to load (search_tickets, not search).
  • Description: what it does, when to use it instead of the neighbouring tool, what it returns, and limits. First sentence carries the decision.
  • Input schema: every argument described; enums instead of free strings; bounds on numbers; required vs optional explicit; IDs typed as strings.
  • Output: a short text summary plus structuredContent matching an output schema when callers need fields.
  • Annotations: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Codex prompts before tools that advertise destructive behavior, but hints are not a security boundary; enforce permissions in the handler.

Also write the server-level instructions text: cross-tool workflow, rate limits, and constraints. Codex reads it at initialization; keep the first 512 characters self-contained.

3. Implement

TypeScript (SDK v2):

// src/server.ts
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

function createServer() {
  const server = new McpServer({ name: 'tickets', version: '1.0.0' });

  server.registerTool(
    'search_tickets',
    {
      description: 'Search support tickets by text. Returns at most `limit` tickets with id, title, status. Use get_ticket for full details.',
      inputSchema: z.object({
        query: z.string().min(2).describe('Words to match in title or body'),
        status: z.enum(['open', 'pending', 'closed']).optional().describe('Filter by status'),
        limit: z.number().int().min(1).max(50).default(10),
      }),
      outputSchema: z.object({ tickets: z.array(z.object({ id: z.string(), title: z.string(), status: z.string() })) }),
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async ({ query, status, limit }) => {
      const tickets = await searchTickets(query, status, limit);   // your data access
      return {
        content: [{ type: 'text', text: tickets.map(t => `${t.id} [${t.status}] ${t.title}`).join('\n') || 'No tickets matched.' }],
        structuredContent: { tickets },
      };
    },
  );
  return server;
}

serveStdio(createServer);
console.error('tickets MCP server ready on stdio');   // stderr only

For HTTP, pass the same factory to createMcpHandler from @modelcontextprotocol/server and mount it with the framework adapter packages (@modelcontextprotocol/express, /hono, /fastify, /node); the factory runs per request, so keep pools and caches at module scope.

Python (SDK v2):

# server.py
from typing import Annotated, Literal
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("tickets")

@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_tickets(
    query: Annotated[str, Field(min_length=2, description="Words to match in title or body")],
    status: Literal["open", "pending", "closed"] | None = None,
    limit: Annotated[int, Field(ge=1, le=50)] = 10,
) -> list[dict]:
    """Search support tickets by text. Returns id, title, status. Use get_ticket for details."""
    rows = db_search(query, status, limit)
    if rows is None:
        raise ToolError("Ticket search is unavailable; retry in a minute.")
    return rows

if __name__ == "__main__":
    mcp.run()   # stdio

Run over HTTP with uv run mcp run server.py --transport streamable-http.

Handler rules:

  • Return expected failures as tool errors (isError: true in TS, ToolError in Python) with a message that says how to fix the call (“No ticket T-99. Known prefixes: T-, INC-”). Reserve exceptions/protocol errors for bugs.
  • Bound every result: paginate with a cursor argument, truncate long text with a note (“showing 50 of 812; narrow the query”). Agents pay for every token you return.
  • Never write logs to stdout on stdio servers: stdout is the JSON-RPC channel.
  • Validate and authorize inside the handler even when the schema looks strict.
  • Make writes idempotent where possible (client-supplied request id) so retries are safe.

4. Test

In-memory tests exercise the real protocol path with no process or port:

# test_server.py  (pytest + anyio)
import pytest
from mcp import Client
from server import mcp

@pytest.fixture
def anyio_backend():
    return "asyncio"

@pytest.mark.anyio
async def test_search_rejects_short_query():
    async with Client(mcp) as client:
        result = await client.call_tool("search_tickets", {"query": "a"})
        assert result.is_error

In TypeScript, connect a Client from @modelcontextprotocol/client to the handler in-process (pass handler.fetch as the transport’s fetch), then client.callTool(...). Cover: schema rejection, empty results, a tool error path, truncation, and one write.

Interactive check:

npx @modelcontextprotocol/inspector node build/server.js
uv run mcp dev server.py

5. Connect to Codex

codex mcp add tickets -- node /abs/path/build/server.js
codex mcp add tickets-py -- uv run --directory /abs/path/tickets mcp run server.py

Restart Codex, run /mcp verbose, then try three prompts a user would really type (“find open tickets about login timeouts”) and read which tools Codex chose and with what arguments. If it picks the wrong tool or passes bad arguments, fix the description or schema, not the prompt. See codex-mcp-servers for approval modes and tool allow lists.

6. Harden

  • HTTP servers: require auth (bearer token verification or OAuth with protected-resource metadata, per the SDK’s authorization guide); bind loopback for local-only servers and keep the SDK’s Host/Origin validation for localhost.
  • Least privilege: the server’s own credentials should only reach what its tools need.
  • Rate-limit and time-limit outbound calls; Codex’s default tool timeout is 60 s.
  • Treat data you return as untrusted for the agent: content fetched from tickets, web pages or emails can contain prompt injection. Label it as data in the text output.

Gotchas

  • A console.log or print on a stdio server corrupts the protocol stream; the host sees a non-JSON line and the connection fails.
  • Tool names collide across servers; generic names (search, get) make the agent guess.
  • Huge enums or deeply nested schemas cost context on every turn. Simplify inputs.
  • Host apps launch stdio servers with a minimal environment and their own working directory. Use absolute paths and pass needed variables explicitly.
  • Returning raw API JSON wholesale (hundreds of fields) degrades tool use; project only the fields the agent needs.

Output contract

**Server:** tickets (TypeScript SDK v2, stdio) at tools/tickets-mcp
**Tools:** search_tickets (read-only), get_ticket (read-only), add_comment (write, idempotent)
**Tests:** 9 in-memory tests pass; Inspector session checked all tools
**Codex check:** /mcp lists 3 tools; 3 sample prompts chose the right tool first time
**Connect:** codex mcp add tickets -- node /abs/path/build/server.js

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "MCP Server Builder"
  short_description: "Tools an agent picks right the first time"
  default_prompt: "$mcp-server-builder Wrap our internal ticketing API as an MCP server Codex can use"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc