You built an MCP server. Now someone asks: "What tools does it expose?"
And you point them at a README. Or tell them to run tools/list and stare at raw JSON. Or you copy-paste the schema into a doc that will drift from reality within a week.
There's a better way. Here's how to get this instead:
Install
# TypeScript
npm install @mcpspec-dev/typescript
# Python
pip install mcpspec-dev
Add one line to your server
TypeScript:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { mcpspec } from "@mcpspec-dev/typescript";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
// Your existing tools, resources, prompts — untouched
const app = mcpspec(server, {
info: { title: "My MCP Server", version: "1.0.0" },
});
app.listen(3000);
Python (FastMCP):
from mcp.server.fastmcp import FastMCP
from mcpspec_dev import McpSpec
mcp = FastMCP("my-server")
# Your existing tools — untouched
spec = McpSpec(mcp, info={"title": "My Server", "version": "1.0.0"})
mcp.run(transport="streamable-http")
That's it. Your server now has three new endpoints.
What you get
/docs — interactive HTML documentation. Dark, light, and high-contrast themes. Collapsible tool/resource/prompt groups. Every input schema rendered with copy-to-clipboard. Generated automatically from what your server actually exposes, so it never drifts.
/mcpspec.yaml — a machine-readable spec in a standardized format. This is the portable artifact: commit it to your repo, feed it to a registry, use it for contract testing, hand it to a security team for audit.
/mcp — your original MCP endpoint, proxied through. Existing clients keep working with zero changes.
A complete example
Here's a real task manager server wired up with MCPSpec:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { mcpspec } from "@mcpspec-dev/typescript";
import { z } from "zod";
const server = new McpServer({ name: "task-manager", version: "1.0.0" });
server.tool("create_task", "Create a new task", {
title: z.string().describe("Task title"),
description: z.string().optional().describe("Optional details"),
priority: z.enum(["low", "medium", "high"]).default("medium"),
}, async ({ title, description, priority }) => {
// ... your implementation
return { content: [{ type: "text", text: `Task created: ${title}` }] };
});
server.tool("list_tasks", "List all tasks", {
status: z.enum(["pending", "done", "all"]).default("all"),
}, async ({ status }) => {
// ... your implementation
return { content: [{ type: "text", text: "[]" }] };
});
const app = mcpspec(server, {
info: {
title: "Task Manager",
version: "1.0.0",
description: "Create and track tasks via MCP",
repository: "https://github.com/you/task-manager",
license: "MIT",
},
groups: {
"Tasks": ["create_task", "list_tasks"],
},
examples: {
create_task: [{
title: "Create a high-priority task",
input: {
title: "Fix production bug",
description: "Users seeing 500 errors on /api/checkout",
priority: "high",
},
}],
},
});
app.listen(3000, () => {
console.log("Docs: http://localhost:3000/docs");
console.log("Spec: http://localhost:3000/mcpspec.yaml");
});
Groups let you organize tools into sections in the docs UI. Examples show up inline next to each tool's schema. Both are optional — the bare minimum is just info.title and info.version.
The spec file
The /mcpspec.yaml output looks like this:
mcpspec: 0.1.0
$schema: "https://mcpspec.dev/schema/0.1.0.json"
info:
name: task-manager
version: "1.0.0"
title: Task Manager
description: Create and track tasks via MCP
tools:
- name: create_task
description: Create a new task
inputSchema:
type: object
properties:
title:
type: string
description: Task title
priority:
type: string
enum: [low, medium, high]
default: medium
required: [title]
- name: list_tasks
description: List all tasks
inputSchema:
type: object
properties:
status:
type: string
enum: [pending, done, all]
default: all
resources: []
prompts: []
Commit this file to your repo. Now any agent, tool, or developer can understand your server's surface area without running it.
Filtering sensitive tools
Not every tool should be public. Use exclude with glob patterns to keep internal capabilities out of the spec:
const app = mcpspec(server, {
info: { title: "My Server", version: "1.0.0" },
exclude: ["internal_*", "admin_*"],
});
Or use include for allowlist mode — only the tools you explicitly name appear in the docs:
const app = mcpspec(server, {
info: { title: "My Server", version: "1.0.0" },
include: ["get_*", "list_*", "search_*"],
});
MCPSpec introspects via in-memory transport and only calls tools/list, resources/list, and prompts/list. It never executes tools or reads resource content. Your auth layer is completely bypassed during introspection — which means the docs endpoint stays accessible even when the MCP endpoint requires a token.
Python (low-level Server API)
If you're not using FastMCP:
from mcp.server.lowlevel import Server
from mcpspec_dev import McpSpec
import uvicorn
server = Server("my-server")
# Register tools via @server.list_tools(), @server.call_tool(), etc.
spec = McpSpec(server, info={"title": "My Server", "version": "1.0.0"})
app = spec.create_app()
uvicorn.run(app, port=3000)
Why this exists
REST APIs had the same problem before OpenAPI. Every API was documented in its own format, or not at all. OpenAPI didn't fix a broken ecosystem — it gave a working one a standard that unlocked everything else: code generators, interactive explorers, contract tests, API registries.
MCP hit 10,000 public servers in under two years. The protocol is solid. But right now every server describes itself differently, or not at all. mcpspec.yaml is the portable spec format that changes that.
The project is MIT licensed. TypeScript on npm, Python on PyPI, source on GitHub.
If you try it and something's missing, open an issue. The spec format is at v0.1.0 — early enough that real usage shapes what it becomes.

Top comments (0)