DEV Community

Lee
Lee

Posted on Originally published at mcp-server-generator-pearl.vercel.app

How to Build an MCP Server Without an OpenAPI Spec (TypeScript and Python)

Most "build an MCP server" guides start from an OpenAPI document and turn every endpoint into a tool. That works if you have a large, well-maintained HTTP API. Often you don't. Maybe you have an internal script, a database query, or a private service with no spec at all. You also don't want 80 auto-generated tools when the model only needs three.

An MCP tool is just a name, a description, and typed parameters. You can write that by hand in a few lines. This post walks through a minimal server in TypeScript and Python. It covers how to test the server on its own, and how to connect it to Claude Desktop and Cursor in a way that doesn't break on path issues.

If you'd rather not type the boilerplate, I made a free MCP Server Generator: you fill in the tools in a form and download this same project as a ZIP. Everything below works the same whether you generate it or write it yourself.

Both versions below were run on Node 20 with @modelcontextprotocol/sdk 1.32.1 and on Python 3.13 with mcp 2.3.0. Both answered tools/list and tools/call correctly.

The TypeScript version

Four files: package.json, tsconfig.json, src/index.ts, README.md. The important one:

#!/usr/bin/env node
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: "weather-helper", version: "1.0.0" });

server.registerTool(
  "get_weather",
  {
    description: "Get current weather for a city",
    inputSchema: z.object({
      city: z.string().describe("City name, e.g. San Francisco"),
    }),
  },
  async ({ city }) => {
    // TODO: replace this placeholder with real logic
    const text = "[get_weather] city=" + JSON.stringify(city);
    return { content: [{ type: "text", text }] };
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("weather-helper MCP server running on stdio");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

What matters here:

  • The Zod schema becomes the JSON Schema the model sees. The .describe() text is the parameter description the model reads. Write it for the model.
  • stdio is the transport. The host talks JSON-RPC over stdin and stdout, so console.log will corrupt the protocol. That's why even the startup message uses console.error.
  • The shebang survives tsc, so the compiled dist/index.js works as a bin entry.

The relevant part of package.json:

{
  "type": "module",
  "bin": { "weather-helper": "./dist/index.js" },
  "scripts": {
    "start": "node --import tsx src/index.ts",
    "build": "tsc -p tsconfig.json"
  },
  "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "zod": "^3.25.76" },
  "devDependencies": { "@types/node": "^22.10.2", "tsx": "^4.19.2", "typescript": "^5.7.2" }
}
Enter fullscreen mode Exit fullscreen mode

npm start runs the TypeScript directly through tsx, so there's no build step while you iterate.

The Python version

"""Example MCP server with a weather lookup tool"""

from mcp.server import MCPServer

mcp = MCPServer("weather-helper", version="1.0.0")

@mcp.tool()
def get_weather(city: str) -> str:
    """Get current weather for a city

    Args:
        city: City name, e.g. San Francisco
    """
    # TODO: replace this placeholder with real logic
    return "[get_weather] city=" + repr(city)

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Two things that trip people up:

  • In mcp 2.x, FastMCP was renamed MCPServer and is imported from mcp.server. Most tutorials still show from mcp.server.fastmcp import FastMCP, which only works on 1.x. Pin mcp>=2.0.0 in requirements.txt if you use the new name.
  • Pass version=. Without it the server reports an empty serverInfo.version to the host.

Type hints (str, float, bool) become the input schema. The docstring becomes the description. A parameter with a default value is optional.

Adding a second tool

Optional parameters are .optional() in Zod and a default value in Python:

server.registerTool(
  "get_forecast",
  {
    description: "Get a multi-day forecast for a city",
    inputSchema: z.object({
      city: z.string().describe("City name"),
      days: z.number().describe("Number of days (1-7)").optional(),
    }),
  },
  async ({ city, days }) => ({
    content: [{ type: "text", text: `[get_forecast] city=${city}, days=${days}` }],
  })
);
Enter fullscreen mode Exit fullscreen mode
@mcp.tool()
def get_forecast(city: str, days: float = 0.0) -> str:
    """Get a multi-day forecast for a city"""
    return f"[get_forecast] city={city!r}, days={days!r}"
Enter fullscreen mode Exit fullscreen mode

Keep tools task-shaped: one clear job each, with a verb in the name. A handful of well-described tools gets picked correctly far more often than one tool per endpoint, and that's the main argument for skipping the spec-to-tools route.

Test it before touching any host config

The MCP Inspector launches your server the same way a host does. It lets you list and call tools in a browser UI:

# TypeScript
npm install
npx @modelcontextprotocol/inspector npm start

# Python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
npx @modelcontextprotocol/inspector .venv/bin/python server.py
Enter fullscreen mode Exit fullscreen mode

If get_weather shows up and echoes city="Paris", the server is fine. Any problem after this point is a config problem.

Connect it to Claude Desktop and Cursor

Both hosts read the same mcpServers format:

  • Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). Settings → Developer → Edit Config opens it.
  • Cursor: ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project).

Use absolute paths everywhere. Desktop apps start your server from their own working directory and without your shell's PATH. Some Claude Desktop builds also ignore the cwd field. Pointing straight at the interpreter and the entry file avoids all of that.

TypeScript (after npm install):

{
  "mcpServers": {
    "weather-helper": {
      "command": "/Users/you/mcp/weather-helper/node_modules/.bin/tsx",
      "args": ["/Users/you/mcp/weather-helper/src/index.ts"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Python (point at the venv's interpreter, so the host uses the one that has mcp installed):

{
  "mcpServers": {
    "weather-helper": {
      "command": "/Users/you/mcp/weather-helper/.venv/bin/python",
      "args": ["/Users/you/mcp/weather-helper/server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you use uv, "command": "uv", "args": ["run", "--directory", "/Users/you/mcp/weather-helper", "server.py"] also works. It installs mcp from pyproject.toml on first run. On Windows, use node_modules\.bin\tsx.cmd or .venv\Scripts\python.exe, and double every backslash in JSON.

Fully quit and reopen Claude Desktop, or toggle the server in Cursor's MCP settings, then ask something that needs the tool.

When it doesn't show up

  • Nothing loads: the JSON is invalid. A single trailing comma disables the whole file.
  • spawn ... ENOENT: the command isn't on the app's PATH. Use an absolute path (which node, which uv).
  • Connects, then drops: something printed to stdout. Log to stderr instead.
  • ModuleNotFoundError: mcp: command points at a Python without the package. Use the venv's Python.
  • Tool listed but never used: the description is vague. Say what the tool does and when to use it.

On macOS, Claude Desktop writes server logs to ~/Library/Logs/Claude/mcp*.log, which is usually the fastest way to see what went wrong.

Wrapping up

You don't need an OpenAPI spec to ship a useful MCP server. You need a few well-named tools, a stdio transport, and a host config with absolute paths. Start with one tool, check it in the Inspector, connect it, then replace the TODO with your real logic.

The free form-based generator writes this exact project for you, in TypeScript or Python, with your tool names and parameters and a README holding the configs above.

Top comments (0)