
Mcp Server Builder
- 81 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
mcp-server-builder is a Claude Code skill that builds production-ready MCP servers with tool definitions, resource providers, prompt templates, and transport configuration.
About
mcp-server-builder is a skill for designing and shipping MCP (Model Context Protocol) servers that expose APIs to AI agents. It covers tool schema design, resource providers, prompt templates, OpenAPI-to-MCP conversion, TypeScript and Python implementations, transport selection, authentication, testing, and deployment. A developer uses it when exposing an internal REST API to Claude, Cursor, or another MCP client, or when building a shared tool server.
- Turns an OpenAPI spec or REST API into typed MCP tools
- Covers TypeScript (@modelcontextprotocol/sdk) and Python (mcp[cli]) servers
- Guides stdio, SSE, and StreamableHTTP transport selection plus Docker deployment
Mcp Server Builder by the numbers
- 81 all-time installs (skills.sh)
- Ranked #5,216 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
mcp-server-builder capabilities & compatibility
- Capabilities
- mcp server · openapi conversion · tool schema design · transport config
- Use cases
- api development
What mcp-server-builder says it does
Build production-ready MCP (Model Context Protocol) servers with tool
Covers OpenAPI-to-MCP conversion, TypeScript and Python
Exposing an internal REST API to Claude, Cursor, or other MCP clients
npx skills add https://github.com/borghei/claude-skills --skill mcp-server-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Convert an API contract into typed MCP tools so AI agents can call it reliably.
Who is it for?
Developers exposing an internal REST API or OpenAPI spec to Claude, Cursor, or other MCP clients as typed tools.
Skip if: Consuming an existing MCP server or writing agent prompts without building a server.
When should I use this skill?
You are exposing an API to AI agents, building a tool server, or converting an OpenAPI spec into MCP tools.
What you get
A production-ready MCP server with well-named tools, correct transport, auth, and tests.
- MCP server implementation
- Tool, resource, and prompt definitions
- Transport and deployment configuration
By the numbers
- 3 transports covered (stdio, SSE, StreamableHTTP)
- 3 bundled scripts: openapi_converter.py, server_scaffolder.py, tool_linter.py
Files
MCP Server Builder
Tier: POWERFUL Category: Engineering / AI Integration Maintainer: Claude Skills Team
Overview
Design and ship production-ready MCP (Model Context Protocol) servers from API contracts. Covers tool definition best practices, resource providers, prompt templates, OpenAPI-to-MCP conversion, TypeScript and Python server implementations, transport selection (stdio, SSE, StreamableHTTP), authentication patterns, testing strategies, and deployment configurations. Treats schema quality and tool discoverability as first-class concerns.
Keywords
MCP, Model Context Protocol, MCP server, tool definition, resource provider, prompt template, stdio transport, SSE transport, OpenAPI to MCP, AI tool server, Claude tools
Core Capabilities
1. Tool Design and Schema Quality
- Verb-noun naming conventions for maximum LLM selection accuracy
- Description engineering with usage context and return value documentation
- Input schema design with proper types, constraints, and descriptions
- Output formatting for LLM consumption (structured text over raw JSON)
2. Server Implementation
- TypeScript server with @modelcontextprotocol/sdk
- Python server with mcp[cli] package
- Tool, resource, and prompt registration patterns
- Error handling with structured error responses
- Middleware for logging, auth, and rate limiting
3. Transport and Deployment
- stdio for local/CLI integration (Claude Code, Cursor)
- SSE for web-based integrations
- StreamableHTTP for production HTTP deployments
- Docker containerization for remote MCP servers
- Health checking and graceful shutdown
4. Testing and Validation
- Tool schema validation (naming, descriptions, types)
- Integration testing with MCP Inspector
- Contract testing with snapshot comparisons
- Load testing for remote server deployments
When to Use
- Exposing an internal REST API to Claude, Cursor, or other MCP clients
- Replacing brittle browser automation with typed tool interfaces
- Building a shared MCP server for multiple teams and AI assistants
- Converting an OpenAPI spec into MCP tools automatically
- Creating domain-specific tool servers (database, monitoring, deployment)
Tool Schema Design
Naming Conventions
Pattern: verb_noun or verb_noun_qualifier
GOOD names:
search_documents — clear action + target
create_github_issue — includes service for disambiguation
get_deployment_status — standard CRUD verb
run_database_query — action implies execution
list_pull_requests — list for collection retrieval
BAD names:
search — search what?
documents — not a verb_noun
doSearch — camelCase, vague
handle_request — implementation detail, not intent
helper — meaninglessDescription Engineering
The description determines whether an LLM selects your tool. Write it for the LLM, not for humans.
Template: "[What it does]. [What it returns]. [When to use it]."
EFFECTIVE:
"Search the codebase for files matching a regex pattern. Returns file paths,
line numbers, and matching content snippets ranked by relevance. Use when
looking for implementations, definitions, or usage of specific code patterns."
INEFFECTIVE:
"Searches files." — no return value, no usage guidance
"A powerful search tool..." — marketing copy
"Wrapper around ripgrep" — implementation detailInput Schema Best Practices
{
"name": "query_database",
"description": "Execute a read-only SQL query against the application database. Returns up to 100 rows as a formatted table. Use when the user needs to look up data, run reports, or investigate database state. Only SELECT statements are allowed.",
"inputSchema": {
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "SQL SELECT query to execute. Must be a read-only query. Example: SELECT id, email, created_at FROM users WHERE created_at > '2026-01-01' LIMIT 10"
},
"database": {
"type": "string",
"enum": ["primary", "analytics", "staging"],
"default": "primary",
"description": "Which database to query. Use 'analytics' for reporting queries on large datasets."
},
"format": {
"type": "string",
"enum": ["table", "json", "csv"],
"default": "table",
"description": "Output format. 'table' is best for display, 'json' for programmatic use."
}
},
"required": ["sql"]
}
}TypeScript MCP Server
Complete Server with Tools, Resources, and Prompts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "project-tools",
version: "1.0.0",
});
// ──── TOOLS ────
server.tool(
"search_codebase",
"Search project files for a regex pattern. Returns file paths, line numbers, and matching lines. Use when looking for code patterns, function definitions, or usage of specific identifiers.",
{
pattern: z.string().describe("Regex pattern to search for. Example: 'async function handle'"),
file_glob: z.string().default("**/*.{ts,tsx,js,jsx}")
.describe("File glob pattern to filter. Example: '**/*.test.ts' for test files only"),
max_results: z.number().int().min(1).max(100).default(20)
.describe("Maximum results to return"),
},
async ({ pattern, file_glob, max_results }) => {
const { execSync } = await import("child_process");
try {
const output = execSync(
`rg --json -e '${pattern.replace(/'/g, "\\'")}' --glob '${file_glob}' --max-count ${max_results}`,
{ cwd: process.env.PROJECT_ROOT || ".", timeout: 10000 }
).toString();
const matches = output
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((entry) => entry.type === "match")
.map((entry) => ({
file: entry.data.path.text,
line: entry.data.line_number,
content: entry.data.lines.text.trim(),
}));
if (matches.length === 0) {
return { content: [{ type: "text", text: `No matches found for pattern: ${pattern}` }] };
}
const formatted = matches
.map((m) => `${m.file}:${m.line} ${m.content}`)
.join("\n");
return {
content: [{ type: "text", text: `Found ${matches.length} matches:\n\n${formatted}` }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Search failed: ${error.message}` }],
isError: true,
};
}
}
);
server.tool(
"run_tests",
"Run the project test suite or specific test files. Returns pass/fail results with failure details. Use when verifying code changes or checking test coverage.",
{
file_pattern: z.string().optional()
.describe("Optional test file pattern. Example: 'auth' to run only auth-related tests"),
coverage: z.boolean().default(false)
.describe("Include coverage report in output"),
},
async ({ file_pattern, coverage }) => {
const { execSync } = await import("child_process");
const args = [file_pattern, coverage ? "--coverage" : ""].filter(Boolean).join(" ");
try {
const output = execSync(`pnpm test ${args}`, {
cwd: process.env.PROJECT_ROOT || ".",
timeout: 120000,
env: { ...process.env, CI: "true" },
}).toString();
return { content: [{ type: "text", text: output }] };
} catch (error) {
return {
content: [{ type: "text", text: `Tests failed:\n\n${error.stdout?.toString() || error.message}` }],
isError: true,
};
}
}
);
// ──── RESOURCES ────
server.resource(
"project://readme",
"project://readme",
async (uri) => {
const fs = await import("fs/promises");
const content = await fs.readFile("README.md", "utf-8");
return {
contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }],
};
}
);
// ──── PROMPTS ────
server.prompt(
"review_code",
"Generate a code review prompt for the given file",
{ file_path: z.string().describe("Path to the file to review") },
async ({ file_path }) => {
const fs = await import("fs/promises");
const content = await fs.readFile(file_path, "utf-8");
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Review this code for bugs, security issues, and improvement opportunities:\n\n\`\`\`\n${content}\n\`\`\``,
},
},
],
};
}
);
// ──── START SERVER ────
const transport = new StdioServerTransport();
await server.connect(transport);Python MCP Server
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import subprocess
import json
server = Server("project-tools")
@server.list_tools()
async def list_tools():
return [
Tool(
name="search_codebase",
description="Search project files for a regex pattern. Returns file paths, line numbers, and matching content. Use when looking for code patterns or definitions.",
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern to search for",
},
"file_type": {
"type": "string",
"enum": ["py", "ts", "go", "rs", "all"],
"default": "all",
"description": "Filter by file type",
},
},
"required": ["pattern"],
},
),
Tool(
name="run_command",
description="Run a shell command in the project directory. Returns stdout and stderr. Use for running tests, linting, or build commands. Only allows pre-approved commands.",
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"enum": ["test", "lint", "build", "typecheck", "format"],
"description": "Pre-approved command to run",
},
},
"required": ["command"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "search_codebase":
return await _search_codebase(arguments)
elif name == "run_command":
return await _run_command(arguments)
else:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
COMMAND_MAP = {
"test": "python -m pytest -v",
"lint": "ruff check .",
"build": "python -m build",
"typecheck": "mypy src/",
"format": "ruff format --check .",
}
async def _run_command(args: dict) -> list[TextContent]:
cmd = COMMAND_MAP.get(args["command"])
if not cmd:
return [TextContent(type="text", text=f"Unknown command: {args['command']}")]
try:
result = subprocess.run(
cmd.split(), capture_output=True, text=True, timeout=120
)
output = result.stdout + ("\n" + result.stderr if result.stderr else "")
return [TextContent(type="text", text=output or "Command completed with no output.")]
except subprocess.TimeoutExpired:
return [TextContent(type="text", text="Command timed out after 120 seconds.")]
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())OpenAPI to MCP Conversion
Conversion Rules
OpenAPI Element → MCP Element
─────────────────────────────────────────────
operationId → Tool name (snake_case)
summary + description → Tool description
parameters + requestBody → inputSchema
responses.200 → Tool output format
securitySchemes → Server auth config
servers[0].url → Base URL for requestsConversion Script Pattern
def openapi_operation_to_mcp_tool(operation: dict, path: str, method: str) -> dict:
"""Convert a single OpenAPI operation to an MCP tool definition."""
# Derive tool name from operationId or path
name = operation.get("operationId")
if not name:
name = f"{method}_{path.replace('/', '_').strip('_')}"
name = name.replace("-", "_").lower()
# Build description from summary + description
summary = operation.get("summary", "")
description = operation.get("description", "")
tool_description = f"{summary}. {description}".strip(". ") + "."
# Build input schema from parameters and request body
properties = {}
required = []
for param in operation.get("parameters", []):
prop = {
"type": param["schema"].get("type", "string"),
"description": param.get("description", ""),
}
if "enum" in param["schema"]:
prop["enum"] = param["schema"]["enum"]
if "default" in param["schema"]:
prop["default"] = param["schema"]["default"]
properties[param["name"]] = prop
if param.get("required"):
required.append(param["name"])
# Request body properties
body_schema = (
operation.get("requestBody", {})
.get("content", {})
.get("application/json", {})
.get("schema", {})
)
if body_schema.get("properties"):
properties.update(body_schema["properties"])
required.extend(body_schema.get("required", []))
return {
"name": name,
"description": tool_description,
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
},
}Client Configuration
Claude Code (claude_desktop_config.json)
{
"mcpServers": {
"project-tools": {
"command": "node",
"args": ["dist/index.js"],
"cwd": "/path/to/mcp-server",
"env": {
"PROJECT_ROOT": "/path/to/project",
"DATABASE_URL": "postgresql://..."
}
},
"remote-api-tools": {
"url": "https://mcp.mycompany.com/sse",
"headers": {
"Authorization": "Bearer ${MCP_API_TOKEN}"
}
}
}
}Testing MCP Servers
Schema Validation
function validateToolSchema(tool: { name: string; description: string; inputSchema: any }): string[] {
const issues: string[] = [];
// Name validation
if (!/^[a-z][a-z0-9_]*$/.test(tool.name)) {
issues.push(`Name '${tool.name}' must be snake_case`);
}
// Description validation
if (tool.description.length < 30) {
issues.push("Description too short for reliable LLM tool selection");
}
if (!tool.description.includes("Use when") && !tool.description.includes("Use for")) {
issues.push("Description should include usage guidance ('Use when...')");
}
// Schema validation
const schema = tool.inputSchema;
if (schema.type !== "object") {
issues.push("inputSchema.type must be 'object'");
}
for (const [prop, def] of Object.entries(schema.properties || {})) {
if (!(def as any).description) {
issues.push(`Property '${prop}' missing description`);
}
}
return issues;
}Integration Testing with MCP Inspector
# Install MCP Inspector
npx @modelcontextprotocol/inspector
# Test stdio server
npx @modelcontextprotocol/inspector node dist/index.js
# Test SSE server
npx @modelcontextprotocol/inspector --url http://localhost:3001/sseVersioning Strategy
- Additive changes (new tools, new optional parameters): non-breaking, bump minor version
- Tool removal or rename: breaking, requires deprecation period + major version bump
- Required parameter addition: breaking, consider making it optional with a default instead
- Response format changes: potentially breaking, document in changelog
Common Pitfalls
- Vague tool descriptions causing the LLM to select the wrong tool or skip yours entirely
- Missing property descriptions leaving the LLM to guess what a parameter means
- No timeout on tool execution allowing runaway processes to hang the server
- Exposing destructive operations without confirmation — add a
confirm: truerequired parameter - Returning raw JSON instead of formatted text — LLMs work better with readable text output
- No error handling causing the server to crash on unexpected input
- Breaking changes without versioning breaking all connected clients simultaneously
Best Practices
1. Description-first design — write the tool description before implementing the handler 2. One intent per tool — a tool that does three things confuses LLM selection 3. Validate inputs in the handler — never trust that the LLM sent correct types 4. Return human-readable text — format output as tables or structured text, not raw JSON 5. Set timeouts on all operations — prevent runaway commands from blocking the server 6. Test tool selection — present your tools to an LLM and verify it picks the right one for various prompts 7. Log every tool call — capture name, inputs, outputs, duration, and errors for debugging 8. Version your tools — maintain backward compatibility for at least one release cycle
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| LLM never selects my tool | Tool description is too vague or missing usage guidance | Rewrite the description using the template: "[What it does]. [What it returns]. [When to use it]." Ensure at least 30 characters and include "Use when..." phrasing |
Tool call returns empty or null | Handler returns raw undefined instead of a content array | Always return { content: [{ type: "text", text: "..." }] } even for empty results — never return bare null or undefined |
ECONNREFUSED when connecting to remote server | SSE/HTTP server not running or wrong port in client config | Verify the server process is listening (curl http://localhost:<port>/sse), check url in claude_desktop_config.json, and confirm no firewall rules block the port |
spawn ENOENT on stdio server startup | The command path in client config points to a missing binary | Use absolute paths for command (e.g., /usr/local/bin/node) or ensure the binary is on the shell PATH that the MCP host process inherits |
| Client connects but lists zero tools | list_tools handler not registered or returns an empty array | Confirm @server.list_tools() (Python) or server.tool() (TypeScript) is called before server.connect(). Test with MCP Inspector to verify the handshake |
| Tool execution times out | No timeout set on subprocess or HTTP calls inside the handler | Add explicit timeout to every subprocess.run(), execSync(), and fetch() call. 10-30 seconds for queries, 120 seconds max for builds |
| Schema validation errors from the client | inputSchema.type is not "object" or required fields are misspelled | Validate your tool definitions with the validateToolSchema function from the Testing section. Every inputSchema must have type: "object" at the top level |
Success Criteria
- Tool selection accuracy ≥ 90% — when presented with 10 natural-language prompts, the LLM picks the correct tool at least 9 times
- Schema validation passes at 100% — every tool clears the
validateToolSchemachecks with zero issues (snake_case name, ≥30-char description, usage guidance, property descriptions) - Median tool response time < 2 seconds — measured end-to-end from tool call to content response for typical queries
- Zero unhandled exceptions — every tool handler catches errors and returns a structured
isError: trueresponse instead of crashing the server - MCP Inspector green path — the server connects, lists tools, executes each tool, and returns valid content through MCP Inspector without manual fixes
- Client configuration works first try — a new developer can copy the provided
claude_desktop_config.jsonsnippet, start the server, and invoke a tool within 5 minutes - OpenAPI conversion coverage ≥ 80% — for a standard OpenAPI 3.x spec, at least 80% of operations convert to usable MCP tools without manual edits
Scope & Limitations
This skill covers:
- Designing tool schemas, resource providers, and prompt templates for MCP servers
- Implementing servers in TypeScript (
@modelcontextprotocol/sdk) and Python (mcp[cli]) - Transport selection and client configuration (stdio, SSE, StreamableHTTP)
- OpenAPI-to-MCP conversion patterns and testing strategies
This skill does NOT cover:
- Building MCP clients or custom LLM orchestration layers — see
engineering/agent-workflow-designer - Designing multi-agent systems that consume MCP tools — see
engineering/agent-designer - API design itself (REST conventions, endpoint naming, versioning) — see
engineering/api-design-reviewer - Infrastructure provisioning, container orchestration, or CI/CD pipelines for deploying MCP servers — see
engineering/ci-cd-pipeline-builder
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
engineering/api-design-reviewer | Review the underlying REST API before converting it to MCP tools | OpenAPI spec → API review findings → refined spec → MCP conversion |
engineering/api-test-suite-builder | Generate integration tests for the HTTP endpoints that MCP tools wrap | MCP tool definitions → endpoint mapping → test suite generation |
engineering/agent-designer | Design agents that consume the MCP tools this skill produces | MCP tool schemas → agent tool inventory → agent behavior design |
engineering/observability-designer | Add structured logging, tracing, and metrics to MCP server handlers | MCP server code → instrumentation plan → logging/tracing middleware |
engineering/ci-cd-pipeline-builder | Automate build, test, and deploy pipelines for MCP server releases | MCP server repo → pipeline config → automated deploy to staging/prod |
engineering/env-secrets-manager | Manage API keys, database credentials, and tokens used in MCP server configs | MCP server env vars → secrets audit → secure injection patterns |
#!/usr/bin/env python3
"""Convert OpenAPI/Swagger specs into MCP tool definitions.
Reads an OpenAPI 3.x or Swagger 2.0 JSON/YAML-style JSON spec and generates
MCP-compatible tool definitions with proper naming, descriptions, and schemas.
Usage:
python openapi_converter.py openapi.json --output tools.json
python openapi_converter.py openapi.json --filter "GET,POST" --json
python openapi_converter.py swagger.json --prefix api --exclude "/internal/*"
"""
import argparse
import json
import re
import sys
from pathlib import Path
from fnmatch import fnmatch
def load_spec(spec_path: str) -> dict:
"""Load and validate an OpenAPI/Swagger spec."""
path = Path(spec_path)
if not path.exists():
raise FileNotFoundError(f"Spec file not found: {spec_path}")
with open(path, "r", encoding="utf-8") as f:
spec = json.load(f)
if "openapi" not in spec and "swagger" not in spec:
raise ValueError("File does not appear to be an OpenAPI 3.x or Swagger 2.0 spec "
"(missing 'openapi' or 'swagger' key)")
return spec
def resolve_ref(spec: dict, ref: str) -> dict:
"""Resolve a simple $ref pointer within the spec (single-level)."""
if not ref.startswith("#/"):
return {}
parts = ref[2:].split("/")
current = spec
for part in parts:
part = part.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict) and part in current:
current = current[part]
else:
return {}
return current if isinstance(current, dict) else {}
def resolve_schema(spec: dict, schema: dict) -> dict:
"""Resolve a schema object, following $ref if present."""
if not isinstance(schema, dict):
return {}
if "$ref" in schema:
return resolve_ref(spec, schema["$ref"])
return schema
def sanitize_name(raw: str) -> str:
"""Convert an operationId or path into a valid snake_case MCP tool name."""
# Remove common prefixes/suffixes
name = raw.strip("/").replace("-", "_").replace(".", "_")
# Convert camelCase to snake_case
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
name = name.lower()
# Replace path separators with underscores
name = re.sub(r"[/{}\s]+", "_", name)
# Remove non-alphanumeric except underscores
name = re.sub(r"[^a-z0-9_]", "", name)
# Collapse multiple underscores
name = re.sub(r"_+", "_", name).strip("_")
return name
def build_method_prefix(method: str) -> str:
"""Map HTTP method to a verb prefix for tool names."""
return {
"get": "get",
"post": "create",
"put": "update",
"patch": "patch",
"delete": "delete",
"head": "check",
"options": "describe",
}.get(method.lower(), method.lower())
def convert_schema_property(spec: dict, prop_schema: dict) -> dict:
"""Convert an OpenAPI schema property to an MCP-compatible property."""
prop_schema = resolve_schema(spec, prop_schema)
result = {}
prop_type = prop_schema.get("type", "string")
if prop_type == "integer":
result["type"] = "number"
elif prop_type == "array":
result["type"] = "array"
items = prop_schema.get("items", {})
items = resolve_schema(spec, items)
if items:
result["items"] = {"type": items.get("type", "string")}
elif prop_type == "object":
result["type"] = "object"
else:
result["type"] = prop_type
if "enum" in prop_schema:
result["enum"] = prop_schema["enum"]
if "default" in prop_schema:
result["default"] = prop_schema["default"]
if "description" in prop_schema:
result["description"] = prop_schema["description"]
elif "title" in prop_schema:
result["description"] = prop_schema["title"]
if "minimum" in prop_schema:
result["minimum"] = prop_schema["minimum"]
if "maximum" in prop_schema:
result["maximum"] = prop_schema["maximum"]
if "pattern" in prop_schema:
result["pattern"] = prop_schema["pattern"]
if "format" in prop_schema:
result["description"] = result.get("description", "") + f" (format: {prop_schema['format']})"
result["description"] = result["description"].strip()
return result
def convert_operation(spec: dict, path: str, method: str, operation: dict,
prefix: str | None = None) -> dict:
"""Convert a single OpenAPI operation to an MCP tool definition."""
# Derive tool name
operation_id = operation.get("operationId")
if operation_id:
name = sanitize_name(operation_id)
else:
verb = build_method_prefix(method)
path_name = sanitize_name(path)
name = f"{verb}_{path_name}"
if prefix:
name = f"{prefix}_{name}"
# Build description
summary = operation.get("summary", "").strip()
description = operation.get("description", "").strip()
if summary and description:
tool_desc = f"{summary}. {description}"
elif summary:
tool_desc = summary
elif description:
tool_desc = description
else:
tool_desc = f"{method.upper()} {path}"
# Ensure it ends with a period
tool_desc = tool_desc.rstrip(".")
tool_desc += "."
# Build inputSchema properties
properties = {}
required = []
# Path and query parameters
params = operation.get("parameters", [])
for param in params:
param = resolve_schema(spec, param)
param_name = param.get("name", "")
if not param_name:
continue
param_schema = param.get("schema", {})
param_schema = resolve_schema(spec, param_schema)
prop = convert_schema_property(spec, param_schema)
if "description" not in prop and param.get("description"):
prop["description"] = param["description"]
if "description" not in prop:
prop["description"] = f"{param.get('in', 'query')} parameter: {param_name}"
properties[param_name] = prop
if param.get("required", False):
required.append(param_name)
# Request body (OpenAPI 3.x)
request_body = operation.get("requestBody", {})
if request_body:
request_body = resolve_schema(spec, request_body)
content = request_body.get("content", {})
json_content = content.get("application/json", {})
body_schema = json_content.get("schema", {})
body_schema = resolve_schema(spec, body_schema)
if body_schema.get("properties"):
for prop_name, prop_def in body_schema["properties"].items():
properties[prop_name] = convert_schema_property(spec, prop_def)
if "description" not in properties[prop_name]:
properties[prop_name]["description"] = f"Request body field: {prop_name}"
required.extend(body_schema.get("required", []))
elif body_schema.get("type") and not body_schema.get("properties"):
properties["body"] = convert_schema_property(spec, body_schema)
if "description" not in properties["body"]:
properties["body"]["description"] = "Request body content"
if request_body.get("required", False):
required.append("body")
# Swagger 2.0 body parameter
body_params = [p for p in params if p.get("in") == "body"]
for bp in body_params:
bp_schema = bp.get("schema", {})
bp_schema = resolve_schema(spec, bp_schema)
if bp_schema.get("properties"):
for prop_name, prop_def in bp_schema["properties"].items():
properties[prop_name] = convert_schema_property(spec, prop_def)
if "description" not in properties[prop_name]:
properties[prop_name]["description"] = f"Request body field: {prop_name}"
required.extend(bp_schema.get("required", []))
# Deduplicate required
required = list(dict.fromkeys(required))
tool = {
"name": name,
"description": tool_desc,
"inputSchema": {
"type": "object",
"properties": properties,
},
}
if required:
tool["inputSchema"]["required"] = required
# Attach source metadata
tool["_source"] = {
"path": path,
"method": method.upper(),
"operationId": operation.get("operationId"),
}
return tool
def convert_spec(spec: dict, prefix: str | None = None,
methods_filter: set | None = None,
exclude_patterns: list[str] | None = None) -> list[dict]:
"""Convert all operations in an OpenAPI spec to MCP tool definitions."""
tools = []
paths = spec.get("paths", {})
for path, path_item in paths.items():
if not isinstance(path_item, dict):
continue
# Check exclusions
if exclude_patterns:
if any(fnmatch(path, pat) for pat in exclude_patterns):
continue
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
if method not in path_item:
continue
if methods_filter and method.upper() not in methods_filter:
continue
operation = path_item[method]
if not isinstance(operation, dict):
continue
# Skip deprecated operations
if operation.get("deprecated", False):
continue
tool = convert_operation(spec, path, method, operation, prefix)
tools.append(tool)
return tools
def main():
parser = argparse.ArgumentParser(
description="Convert OpenAPI/Swagger specs into MCP tool definitions.",
epilog="Example: %(prog)s openapi.json --output mcp-tools.json",
)
parser.add_argument("spec", help="Path to OpenAPI 3.x or Swagger 2.0 JSON spec file")
parser.add_argument(
"--output", "-o", default=None,
help="Output file for MCP tool definitions (default: stdout)",
)
parser.add_argument(
"--prefix", default=None,
help="Prefix to add to all tool names (e.g., 'github' -> 'github_list_repos')",
)
parser.add_argument(
"--filter", default=None,
help="Comma-separated HTTP methods to include (e.g., 'GET,POST')",
)
parser.add_argument(
"--exclude", action="append", default=[],
help="Glob pattern for paths to exclude (e.g., '/internal/*'). Can be repeated.",
)
parser.add_argument(
"--no-source", action="store_true",
help="Omit _source metadata from output",
)
parser.add_argument(
"--json", action="store_true",
help="Output results as JSON with metadata (default for stdout is human-readable)",
)
args = parser.parse_args()
try:
spec = load_spec(args.spec)
except (FileNotFoundError, ValueError, json.JSONDecodeError) as e:
if args.json:
json.dump({"error": str(e)}, sys.stdout, indent=2)
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
methods_filter = None
if args.filter:
methods_filter = {m.strip().upper() for m in args.filter.split(",")}
tools = convert_spec(spec, prefix=args.prefix, methods_filter=methods_filter,
exclude_patterns=args.exclude or None)
if args.no_source:
for tool in tools:
tool.pop("_source", None)
# Detect spec info
spec_title = spec.get("info", {}).get("title", "Unknown API")
spec_version = spec.get("info", {}).get("version", "unknown")
total_paths = len(spec.get("paths", {}))
result = {
"source": {
"title": spec_title,
"version": spec_version,
"total_paths": total_paths,
},
"conversion": {
"tools_generated": len(tools),
"methods_filter": list(methods_filter) if methods_filter else None,
"exclude_patterns": args.exclude or None,
"prefix": args.prefix,
},
"tools": tools,
}
if args.output:
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2)
f.write("\n")
if args.json:
summary = {k: v for k, v in result.items() if k != "tools"}
summary["output_file"] = str(out_path.resolve())
json.dump(summary, sys.stdout, indent=2)
print()
else:
print(f"Converted: {spec_title} v{spec_version}")
print(f"Paths in spec: {total_paths}")
print(f"Tools generated: {len(tools)}")
print(f"Output: {out_path.resolve()}")
if tools:
print(f"\nTools:")
for t in tools:
src = t.get("_source", {})
method = src.get("method", "?")
path = src.get("path", "?")
props = len(t.get("inputSchema", {}).get("properties", {}))
print(f" {t['name']:40s} {method:6s} {path:30s} ({props} params)")
else:
if args.json:
json.dump(result, sys.stdout, indent=2)
print()
else:
print(f"# {spec_title} v{spec_version}")
print(f"# {total_paths} paths -> {len(tools)} MCP tools\n")
for i, tool in enumerate(tools):
src = tool.get("_source", {})
print(f"## Tool {i + 1}: {tool['name']}")
print(f" Source: {src.get('method', '?')} {src.get('path', '?')}")
print(f" Description: {tool['description']}")
schema = tool.get("inputSchema", {})
props = schema.get("properties", {})
req = schema.get("required", [])
if props:
print(f" Parameters ({len(props)}):")
for pname, pdef in props.items():
req_mark = "*" if pname in req else " "
ptype = pdef.get("type", "?")
pdesc = pdef.get("description", "")[:60]
print(f" {req_mark} {pname}: {ptype} — {pdesc}")
else:
print(f" Parameters: none")
print()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate MCP server boilerplate (TypeScript or Python) from a tool definition JSON config.
Reads a JSON config file containing server metadata and tool definitions, then
generates a complete, runnable MCP server implementation in the target language.
Usage:
python server_scaffolder.py config.json --lang typescript --output ./server
python server_scaffolder.py config.json --lang python --output ./server --json
"""
import argparse
import json
import os
import sys
import textwrap
from pathlib import Path
def load_config(config_path: str) -> dict:
"""Load and validate the tool definition config."""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(path, "r", encoding="utf-8") as f:
config = json.load(f)
errors = []
if "name" not in config:
errors.append("Missing required field: 'name'")
if "tools" not in config or not isinstance(config.get("tools"), list):
errors.append("Missing or invalid 'tools' array")
else:
for i, tool in enumerate(config["tools"]):
if "name" not in tool:
errors.append(f"Tool [{i}] missing 'name'")
if "description" not in tool:
errors.append(f"Tool [{i}] missing 'description'")
if errors:
raise ValueError("Config validation failed:\n " + "\n ".join(errors))
return config
def generate_typescript(config: dict) -> dict[str, str]:
"""Generate TypeScript MCP server files."""
server_name = config["name"]
version = config.get("version", "1.0.0")
tools = config.get("tools", [])
# Build tool registrations
tool_blocks = []
for tool in tools:
name = tool["name"]
desc = tool["description"]
schema = tool.get("inputSchema", {})
properties = schema.get("properties", {})
required = schema.get("required", [])
# Build zod schema lines
zod_lines = []
for prop_name, prop_def in properties.items():
prop_type = prop_def.get("type", "string")
prop_desc = prop_def.get("description", "")
zod_type_map = {
"string": "z.string()",
"number": "z.number()",
"integer": "z.number().int()",
"boolean": "z.boolean()",
"array": "z.array(z.any())",
}
zod_type = zod_type_map.get(prop_type, "z.string()")
if "enum" in prop_def:
enum_vals = ", ".join(f'"{v}"' for v in prop_def["enum"])
zod_type = f"z.enum([{enum_vals}])"
if "default" in prop_def:
default_val = prop_def["default"]
if isinstance(default_val, str):
zod_type += f'.default("{default_val}")'
elif isinstance(default_val, bool):
zod_type += f".default({'true' if default_val else 'false'})"
else:
zod_type += f".default({default_val})"
if prop_name not in required:
zod_type += ".optional()"
if prop_desc:
zod_type += f'.describe("{prop_desc}")'
zod_lines.append(f" {prop_name}: {zod_type},")
zod_schema = "{\n" + "\n".join(zod_lines) + "\n }" if zod_lines else "{}"
params = ", ".join(properties.keys())
param_destructure = f"{{ {params} }}" if params else "_args"
block = textwrap.dedent(f"""\
server.tool(
"{name}",
"{desc}",
{zod_schema},
async ({param_destructure}) => {{
// TODO: Implement {name}
return {{
content: [{{ type: "text", text: "Tool {name} executed successfully." }}],
}};
}}
);
""")
tool_blocks.append(block)
tools_code = "\n".join(tool_blocks)
index_ts = textwrap.dedent(f"""\
import {{ McpServer }} from "@modelcontextprotocol/sdk/server/mcp.js";
import {{ StdioServerTransport }} from "@modelcontextprotocol/sdk/server/stdio.js";
import {{ z }} from "zod";
const server = new McpServer({{
name: "{server_name}",
version: "{version}",
}});
// ──── TOOLS ────
{tools_code}
// ──── START SERVER ────
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("{server_name} MCP server running on stdio");
""")
package_json = json.dumps({
"name": server_name,
"version": version,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.22.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.7.0",
"@types/node": "^20.0.0"
}
}, indent=2) + "\n"
tsconfig = json.dumps({
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": True,
"esModuleInterop": True,
"skipLibCheck": True,
"declaration": True
},
"include": ["src/**/*"]
}, indent=2) + "\n"
return {
"src/index.ts": index_ts,
"package.json": package_json,
"tsconfig.json": tsconfig,
}
def generate_python(config: dict) -> dict[str, str]:
"""Generate Python MCP server files."""
server_name = config["name"]
tools = config.get("tools", [])
# Build tool list entries
tool_list_entries = []
handler_blocks = []
for tool in tools:
name = tool["name"]
desc = tool["description"]
schema = tool.get("inputSchema", {
"type": "object", "properties": {}, "required": []
})
schema_str = json.dumps(schema, indent=12)
tool_list_entries.append(textwrap.dedent(f"""\
Tool(
name="{name}",
description="{desc}",
inputSchema={schema_str},
),"""))
handler_blocks.append(textwrap.dedent(f"""\
if name == "{name}":
# TODO: Implement {name}
return [TextContent(type="text", text="Tool {name} executed successfully.")]
"""))
tools_list_code = "\n ".join(tool_list_entries)
handlers_code = " el".join(handler_blocks) if len(handler_blocks) > 1 else \
" " + handler_blocks[0] if handler_blocks else \
' return [TextContent(type="text", text=f"Unknown tool: {name}")]'
# For elif chaining, the first block uses "if", rest use "elif" via the join
server_py = textwrap.dedent(f"""\
#!/usr/bin/env python3
\"\"\"MCP server: {server_name}.\"\"\"
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("{server_name}")
@server.list_tools()
async def list_tools():
return [
{tools_list_code}
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
{handlers_code}
return [TextContent(type="text", text=f"Unknown tool: {{name}}")]
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())
""")
pyproject = textwrap.dedent(f"""\
[project]
name = "{server_name}"
version = "{config.get('version', '1.0.0')}"
requires-python = ">=3.10"
dependencies = ["mcp[cli]>=1.0.0"]
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.backends._legacy:_Backend"
""")
return {
"server.py": server_py,
"pyproject.toml": pyproject,
}
def write_files(files: dict[str, str], output_dir: str, dry_run: bool = False) -> list[dict]:
"""Write generated files to disk. Returns metadata about written files."""
results = []
out = Path(output_dir)
for rel_path, content in files.items():
full_path = out / rel_path
results.append({
"path": str(full_path),
"size_bytes": len(content.encode("utf-8")),
"lines": content.count("\n"),
})
if not dry_run:
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
if rel_path.endswith(".py"):
os.chmod(full_path, 0o755)
return results
def main():
parser = argparse.ArgumentParser(
description="Generate MCP server boilerplate from a tool definition JSON config.",
epilog="Example: %(prog)s config.json --lang typescript --output ./my-server",
)
parser.add_argument("config", help="Path to tool definition JSON config file")
parser.add_argument(
"--lang", choices=["typescript", "python"], default="typescript",
help="Target language for server generation (default: typescript)",
)
parser.add_argument(
"--output", "-o", default="./mcp-server",
help="Output directory for generated files (default: ./mcp-server)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Show what would be generated without writing files",
)
parser.add_argument(
"--json", action="store_true",
help="Output results as JSON instead of human-readable text",
)
args = parser.parse_args()
try:
config = load_config(args.config)
except (FileNotFoundError, ValueError, json.JSONDecodeError) as e:
if args.json:
json.dump({"error": str(e)}, sys.stdout, indent=2)
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if args.lang == "typescript":
files = generate_typescript(config)
else:
files = generate_python(config)
results = write_files(files, args.output, dry_run=args.dry_run)
output = {
"server_name": config["name"],
"language": args.lang,
"output_dir": str(Path(args.output).resolve()),
"tools_count": len(config.get("tools", [])),
"files": results,
"dry_run": args.dry_run,
}
if args.json:
json.dump(output, sys.stdout, indent=2)
print()
else:
action = "Would generate" if args.dry_run else "Generated"
print(f"{action} {args.lang} MCP server: {config['name']}")
print(f"Output directory: {output['output_dir']}")
print(f"Tools: {output['tools_count']}")
print(f"\nFiles:")
for f in results:
print(f" {f['path']} ({f['lines']} lines, {f['size_bytes']} bytes)")
if not args.dry_run:
if args.lang == "typescript":
print(f"\nNext steps:\n cd {args.output}\n npm install\n npm run dev")
else:
print(f"\nNext steps:\n cd {args.output}\n pip install -e .\n python server.py")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Lint MCP tool definitions for naming conventions, description quality, and schema completeness.
Validates tool definitions against MCP best practices including snake_case naming,
description engineering quality, inputSchema completeness, and property documentation.
Usage:
python tool_linter.py tools.json
python tool_linter.py tools.json --strict --json
python tool_linter.py server_config.json --path tools
"""
import argparse
import json
import re
import sys
from pathlib import Path
SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
VERB_NOUN_RE = re.compile(r"^[a-z]+_[a-z]")
CAMEL_CASE_RE = re.compile(r"[a-z][A-Z]")
GOOD_VERBS = {
"get", "list", "search", "create", "update", "delete", "run", "execute",
"check", "validate", "send", "fetch", "query", "find", "set", "add",
"remove", "start", "stop", "restart", "deploy", "build", "test",
"analyze", "generate", "export", "import", "sync", "verify", "configure",
"publish", "subscribe", "read", "write", "count", "compute", "transform",
}
BAD_NAME_PATTERNS = [
(re.compile(r"^(handle|process|do|helper|util|misc|manager)"), "Name uses vague/implementation-detail verb"),
(re.compile(r"^[a-z]+$"), "Single-word name lacks noun — unclear what is acted upon"),
(re.compile(r"[A-Z]"), "Contains uppercase characters — must be snake_case"),
(re.compile(r"-"), "Contains hyphens — use underscores for snake_case"),
]
SEVERITY_ERROR = "error"
SEVERITY_WARNING = "warning"
SEVERITY_INFO = "info"
def lint_name(tool: dict) -> list[dict]:
"""Lint the tool name for MCP conventions."""
issues = []
name = tool.get("name", "")
if not name:
issues.append({"rule": "name-required", "severity": SEVERITY_ERROR,
"message": "Tool is missing a 'name' field"})
return issues
if not SNAKE_CASE_RE.match(name):
issues.append({"rule": "name-snake-case", "severity": SEVERITY_ERROR,
"message": f"Name '{name}' is not valid snake_case"})
if CAMEL_CASE_RE.search(name):
issues.append({"rule": "name-no-camel", "severity": SEVERITY_ERROR,
"message": f"Name '{name}' uses camelCase — convert to snake_case"})
for pattern, msg in BAD_NAME_PATTERNS:
if pattern.search(name):
issues.append({"rule": "name-quality", "severity": SEVERITY_WARNING,
"message": f"Name '{name}': {msg}"})
if not VERB_NOUN_RE.match(name):
issues.append({"rule": "name-verb-noun", "severity": SEVERITY_WARNING,
"message": f"Name '{name}' should follow verb_noun pattern (e.g., search_documents)"})
parts = name.split("_")
if parts and parts[0] not in GOOD_VERBS:
issues.append({"rule": "name-known-verb", "severity": SEVERITY_INFO,
"message": f"Name '{name}' starts with uncommon verb '{parts[0]}' — "
f"consider standard verbs: get, list, search, create, update, delete, run"})
if len(name) > 64:
issues.append({"rule": "name-length", "severity": SEVERITY_WARNING,
"message": f"Name '{name}' is {len(name)} chars — keep under 64 for readability"})
return issues
def lint_description(tool: dict) -> list[dict]:
"""Lint the tool description for LLM discoverability."""
issues = []
name = tool.get("name", "<unnamed>")
desc = tool.get("description", "")
if not desc:
issues.append({"rule": "desc-required", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' is missing a description"})
return issues
if len(desc) < 30:
issues.append({"rule": "desc-min-length", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' description is {len(desc)} chars — "
f"minimum 30 for reliable LLM tool selection"})
if len(desc) > 1024:
issues.append({"rule": "desc-max-length", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' description is {len(desc)} chars — "
f"consider keeping under 1024 to avoid token waste"})
has_usage = any(phrase in desc.lower() for phrase in [
"use when", "use for", "use this", "useful for", "use to",
"helpful when", "call this", "invoke this",
])
if not has_usage:
issues.append({"rule": "desc-usage-guidance", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' description lacks usage guidance — "
f"add 'Use when...' to help LLMs decide when to select it"})
has_return = any(phrase in desc.lower() for phrase in [
"returns", "outputs", "produces", "responds with", "result",
])
if not has_return:
issues.append({"rule": "desc-return-value", "severity": SEVERITY_INFO,
"message": f"Tool '{name}' description does not mention return value — "
f"add 'Returns...' to set LLM expectations"})
if not desc.rstrip().endswith("."):
issues.append({"rule": "desc-punctuation", "severity": SEVERITY_INFO,
"message": f"Tool '{name}' description should end with a period"})
vague_starts = ["a tool", "this tool", "tool for", "a powerful", "an advanced", "wrapper"]
if any(desc.lower().startswith(v) for v in vague_starts):
issues.append({"rule": "desc-no-fluff", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' description starts with vague phrasing — "
f"lead with what it does, not marketing copy"})
return issues
def lint_schema(tool: dict) -> list[dict]:
"""Lint the inputSchema for completeness."""
issues = []
name = tool.get("name", "<unnamed>")
schema = tool.get("inputSchema", None)
if schema is None:
issues.append({"rule": "schema-required", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' has no inputSchema — "
f"add one even if empty (type: object, properties: {{}})"})
return issues
if not isinstance(schema, dict):
issues.append({"rule": "schema-type-check", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' inputSchema must be an object"})
return issues
if schema.get("type") != "object":
issues.append({"rule": "schema-root-type", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' inputSchema.type must be 'object', "
f"got '{schema.get('type', '<missing>')}'"})
properties = schema.get("properties", {})
required = schema.get("required", [])
for req in required:
if req not in properties:
issues.append({"rule": "schema-required-exists", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' lists '{req}' as required "
f"but it is not in properties"})
for prop_name, prop_def in properties.items():
if not isinstance(prop_def, dict):
issues.append({"rule": "schema-prop-object", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' property '{prop_name}' must be an object"})
continue
if "description" not in prop_def or not prop_def["description"]:
issues.append({"rule": "schema-prop-description", "severity": SEVERITY_ERROR,
"message": f"Tool '{name}' property '{prop_name}' is missing a description"})
if "type" not in prop_def and "enum" not in prop_def and "$ref" not in prop_def:
issues.append({"rule": "schema-prop-type", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' property '{prop_name}' has no type specified"})
if prop_def.get("type") == "string" and "enum" not in prop_def and \
"description" in prop_def and len(prop_def["description"]) < 10:
issues.append({"rule": "schema-prop-desc-quality", "severity": SEVERITY_INFO,
"message": f"Tool '{name}' property '{prop_name}' has a very short "
f"description — add examples or constraints"})
if len(properties) > 15:
issues.append({"rule": "schema-prop-count", "severity": SEVERITY_WARNING,
"message": f"Tool '{name}' has {len(properties)} properties — "
f"consider splitting into multiple tools (one intent per tool)"})
return issues
def lint_tool(tool: dict) -> list[dict]:
"""Run all lint rules against a single tool definition."""
issues = []
issues.extend(lint_name(tool))
issues.extend(lint_description(tool))
issues.extend(lint_schema(tool))
return issues
def load_tools(file_path: str, json_path: str | None) -> list[dict]:
"""Load tool definitions from a JSON file, optionally at a nested path."""
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
if json_path:
for key in json_path.split("."):
if isinstance(data, dict) and key in data:
data = data[key]
elif isinstance(data, list):
try:
data = data[int(key)]
except (ValueError, IndexError):
raise ValueError(f"Cannot traverse path '{json_path}' — "
f"key '{key}' not found in array")
else:
raise ValueError(f"Cannot traverse path '{json_path}' — "
f"key '{key}' not found")
if isinstance(data, dict) and "name" in data:
return [data]
if isinstance(data, list):
return data
raise ValueError(f"Expected a tool object or array of tools, got {type(data).__name__}")
def main():
parser = argparse.ArgumentParser(
description="Lint MCP tool definitions for naming, description quality, and schema completeness.",
epilog="Example: %(prog)s tools.json --strict",
)
parser.add_argument("file", help="JSON file containing MCP tool definitions")
parser.add_argument(
"--path", default=None,
help="Dot-separated path to tools array within the JSON (e.g., 'tools' or 'server.tools')",
)
parser.add_argument(
"--strict", action="store_true",
help="Treat warnings as errors (exit code 1 if any warnings found)",
)
parser.add_argument(
"--json", action="store_true",
help="Output results as JSON instead of human-readable text",
)
args = parser.parse_args()
try:
tools = load_tools(args.file, args.path)
except (FileNotFoundError, ValueError, json.JSONDecodeError) as e:
if args.json:
json.dump({"error": str(e)}, sys.stdout, indent=2)
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
all_results = []
total_errors = 0
total_warnings = 0
total_info = 0
for tool in tools:
issues = lint_tool(tool)
tool_name = tool.get("name", "<unnamed>")
errors = [i for i in issues if i["severity"] == SEVERITY_ERROR]
warnings = [i for i in issues if i["severity"] == SEVERITY_WARNING]
infos = [i for i in issues if i["severity"] == SEVERITY_INFO]
total_errors += len(errors)
total_warnings += len(warnings)
total_info += len(infos)
all_results.append({
"tool": tool_name,
"issues": issues,
"counts": {"errors": len(errors), "warnings": len(warnings), "info": len(infos)},
"passed": len(errors) == 0 and (not args.strict or len(warnings) == 0),
})
output = {
"tools_checked": len(tools),
"total_errors": total_errors,
"total_warnings": total_warnings,
"total_info": total_info,
"all_passed": total_errors == 0 and (not args.strict or total_warnings == 0),
"results": all_results,
}
if args.json:
json.dump(output, sys.stdout, indent=2)
print()
else:
for result in all_results:
status = "PASS" if result["passed"] else "FAIL"
print(f"\n[{status}] {result['tool']}")
if not result["issues"]:
print(" No issues found.")
for issue in result["issues"]:
sev = issue["severity"].upper()
prefix = {"ERROR": "E", "WARNING": "W", "INFO": "I"}.get(sev, "?")
print(f" {prefix} [{issue['rule']}] {issue['message']}")
print(f"\n{'=' * 60}")
print(f"Tools checked: {len(tools)}")
print(f"Errors: {total_errors} Warnings: {total_warnings} Info: {total_info}")
if output["all_passed"]:
print("Result: ALL PASSED")
else:
print("Result: ISSUES FOUND")
sys.exit(0 if output["all_passed"] else 1)
if __name__ == "__main__":
main()
Related skills
FAQ
What languages does mcp-server-builder support?
It covers TypeScript with @modelcontextprotocol/sdk and Python with the mcp[cli] package.
Can it convert an OpenAPI spec into MCP tools?
Yes, it covers OpenAPI-to-MCP conversion and ships an openapi_converter.py script.