Tools: The Complete Guide to MCP Servers

Tools: The Complete Guide to MCP Servers

What is an MCP server?

Two ways to build an MCP server

Option A: Write it by hand

Option B: Declare it with paso

Getting started

Install

Create a declaration

Testing before you ship

Validate

Strict mode

Inspect

Dry run

Doctor

Permissions and safety

Permission tiers

Consent gates

Constraints

Forbidden list

Connecting to clients

Claude Desktop

Cursor

VS Code

Windsurf

Other MCP clients

Performance

Optimization tips

How paso compares to hand-written servers

Common patterns

One API, one declaration

Start with read, add write later

Use OpenAPI as a starting point

CI validation

What's next MCP (Model Context Protocol) is how AI agents connect to your API. An MCP server exposes your API's capabilities as tools that agents like Claude, Cursor, and Copilot can discover and call at runtime. This guide covers everything: what MCP servers are, how to build one, how to test it, how to secure it, and how to deploy it. Whether you're writing one from scratch or generating one from a YAML file, this is the reference. An MCP server is a process that speaks the Model Context Protocol. It sits between your API and an AI agent, translating your endpoints into typed tools the agent can understand and call. The protocol was created by Anthropic in late 2024 and donated to the Linux Foundation's Agentic AI Foundation in December 2025. It's an open standard. Over 5,000 MCP servers exist as of early 2026. An MCP server does three things: The key difference between MCP and a regular REST API: MCP clients discover available actions at runtime. A developer hardcodes API calls. An agent discovers them dynamically, decides which to use based on context, and constructs the inputs itself. For a deeper comparison with REST and GraphQL, see MCP vs REST vs GraphQL: What Changes When AI is the Client. The MCP TypeScript SDK gives you full control. You register tools, define input schemas, and write handler functions. That's one tool. For a real API with 6 capabilities, you're looking at 200+ lines. Each tool needs input validation, URL construction, auth forwarding, and error handling. It works. It's just a lot of repetitive code. paso takes a different approach. You describe your API in a YAML file and paso generates the MCP server. Same MCP server. Same protocol compliance. 30 lines instead of 200+. The YAML file is called a paso declaration. See What is a paso Declaration? for a field-by-field breakdown. For a detailed side-by-side comparison, see How to Create an MCP Server and paso vs Writing MCP Servers by Hand. Both produce the same MCP server from the same YAML file. See paso Works the Same in Python for the Python walkthrough. This generates a usepaso.yaml template. Edit it to describe your API's capabilities. If you have an OpenAPI spec, skip writing YAML by hand: paso converts OpenAPI 3.x specs into declarations. See OpenAPI to MCP in 60 Seconds for the walkthrough. Your API is now accessible to MCP clients. Never connect an untested MCP server to an agent. paso gives you five ways to verify your declaration before going live. Catches structural issues: missing fields, invalid URLs, path parameter mismatches, duplicate names. See Common MCP Server Errors for the seven errors you're most likely to hit and how to fix each one. Flags best-practice issues: DELETE without consent gates, short descriptions, write operations without constraints. Shows exactly what MCP tools your declaration produces. This is what agents see. Previews the exact HTTP request without sending it. Verify URL construction, parameter placement, and auth headers. End-to-end check: file exists, YAML parses, validation passes, auth token is set, base URL is reachable. If doctor passes, you're ready to serve. For the full testing workflow, see Five Ways to Test Before You Ship. An MCP server without permissions is an API with no access controls. Any agent can call any tool. That includes DELETE. paso provides four layers of safety. See What Happens When an Agent Calls DELETE for why each one matters. Every capability has a permission field: read, write, or admin. Force the agent to ask the user before executing sensitive operations. Rate limits and guardrails. Explicitly block capabilities from being exposed to agents. A capability in forbidden is never registered as an MCP tool. It doesn't exist as far as the agent is concerned. paso writes the config file for you. Restart Claude Desktop. Your capabilities appear as tools. For the full walkthrough, see Connect Stripe to Claude Desktop in 5 Minutes. The same pattern works for any API. paso writes .cursor/mcp.json in your project root. Restart Cursor to connect. paso writes .vscode/mcp.json in your project root. Reload VS Code to connect. paso writes the Windsurf config file. Restart Windsurf to connect. Any MCP-compatible client can connect. The server speaks standard MCP over stdio. No client-specific code needed. paso adds minimal overhead on top of the MCP protocol. The cold start is mostly Node.js module loading. The per-request cost is input validation and HTTP request construction. single-digit milliseconds. For detailed benchmarks and optimization tips, see MCP Server Performance: What to Expect. paso handles MCP today. When A2A or the next protocol arrives, your declaration doesn't change. The protocol layer is paso's problem, not yours. Each usepaso.yaml describes one API. If you have Sentry, Stripe, and GitHub, that's three declarations, three servers. Expose read-only capabilities first. Verify they work. Then add write operations with consent gates. If you have an OpenAPI spec, generate the declaration instead of writing it: Review the output, set appropriate permissions, remove capabilities you don't want exposed, then validate and serve. Add validation to your CI pipeline: Returns structured output with a non-zero exit code on failure. Treat it like a linter. MCP is one year old. The ecosystem is growing fast. Over 5,000 servers exist. The protocol is moving from local development tools to production infrastructure with Streamable HTTP transport for remote servers. paso's bet: the protocol layer should be abstracted. You describe what your API can do. paso handles how it's exposed. When the protocol changes, your declaration stays the same. Your API is one YAML file away from being agent-ready. All guides in this series: Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ 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: "Sentry", version: "1.0.0", }); server.tool( "list_issues", "List issues in a Sentry project", { organization_slug: z.string(), project_slug: z.string(), query: z.string().optional(), }, async ({ organization_slug, project_slug, query }) => { const url = new URL( `https://sentry.io/api/0/projects/${organization_slug}/${project_slug}/issues/` ); if (query) url.searchParams.set("query", query); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${process.env.SENTRY_AUTH_TOKEN}`, }, }); const data = await res.json(); return { content: [{ type: "text", text: JSON.stringify(data) }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport); 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: "Sentry", version: "1.0.0", }); server.tool( "list_issues", "List issues in a Sentry project", { organization_slug: z.string(), project_slug: z.string(), query: z.string().optional(), }, async ({ organization_slug, project_slug, query }) => { const url = new URL( `https://sentry.io/api/0/projects/${organization_slug}/${project_slug}/issues/` ); if (query) url.searchParams.set("query", query); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${process.env.SENTRY_AUTH_TOKEN}`, }, }); const data = await res.json(); return { content: [{ type: "text", text: JSON.stringify(data) }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport); 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: "Sentry", version: "1.0.0", }); server.tool( "list_issues", "List issues in a Sentry project", { organization_slug: z.string(), project_slug: z.string(), query: z.string().optional(), }, async ({ organization_slug, project_slug, query }) => { const url = new URL( `https://sentry.io/api/0/projects/${organization_slug}/${project_slug}/issues/` ); if (query) url.searchParams.set("query", query); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${process.env.SENTRY_AUTH_TOKEN}`, }, }); const data = await res.json(); return { content: [{ type: "text", text: JSON.stringify(data) }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport); version: "1.0" -weight: 500;">service: name: Sentry description: Error monitoring and performance tracking base_url: https://sentry.io/api/0 auth: type: bearer capabilities: - name: list_issues description: List issues in a project method: GET path: /projects/{organization_slug}/{project_slug}/issues/ permission: read inputs: organization_slug: type: string required: true description: Organization slug in: path project_slug: type: string required: true description: Project slug in: path query: type: string description: "Search query (e.g., 'is:unresolved')" in: query version: "1.0" -weight: 500;">service: name: Sentry description: Error monitoring and performance tracking base_url: https://sentry.io/api/0 auth: type: bearer capabilities: - name: list_issues description: List issues in a project method: GET path: /projects/{organization_slug}/{project_slug}/issues/ permission: read inputs: organization_slug: type: string required: true description: Organization slug in: path project_slug: type: string required: true description: Project slug in: path query: type: string description: "Search query (e.g., 'is:unresolved')" in: query version: "1.0" -weight: 500;">service: name: Sentry description: Error monitoring and performance tracking base_url: https://sentry.io/api/0 auth: type: bearer capabilities: - name: list_issues description: List issues in a project method: GET path: /projects/{organization_slug}/{project_slug}/issues/ permission: read inputs: organization_slug: type: string required: true description: Organization slug in: path project_slug: type: string required: true description: Project slug in: path query: type: string description: "Search query (e.g., 'is:unresolved')" in: query usepaso serve usepaso serve usepaso serve -weight: 500;">npm -weight: 500;">install -g usepaso -weight: 500;">npm -weight: 500;">install -g usepaso -weight: 500;">npm -weight: 500;">install -g usepaso -weight: 500;">pip -weight: 500;">install usepaso -weight: 500;">pip -weight: 500;">install usepaso -weight: 500;">pip -weight: 500;">install usepaso usepaso init --name "Sentry" usepaso init --name "Sentry" usepaso init --name "Sentry" usepaso init --from-openapi ./openapi.json usepaso init --from-openapi ./openapi.json usepaso init --from-openapi ./openapi.json export USEPASO_AUTH_TOKEN="your-api-token" usepaso serve export USEPASO_AUTH_TOKEN="your-api-token" usepaso serve export USEPASO_AUTH_TOKEN="your-api-token" usepaso serve usepaso serving "Sentry" (6 capabilities). Agents welcome. usepaso serving "Sentry" (6 capabilities). Agents welcome. usepaso serving "Sentry" (6 capabilities). Agents welcome. usepaso validate usepaso validate usepaso validate valid (Sentry, 6 capabilities, 0 regrets) valid (Sentry, 6 capabilities, 0 regrets) valid (Sentry, 6 capabilities, 0 regrets) usepaso validate --strict usepaso validate --strict usepaso validate --strict usepaso inspect usepaso inspect usepaso inspect usepaso test list_issues \ -p organization_slug=my-org \ -p project_slug=my-project \ --dry-run usepaso test list_issues \ -p organization_slug=my-org \ -p project_slug=my-project \ --dry-run usepaso test list_issues \ -p organization_slug=my-org \ -p project_slug=my-project \ --dry-run usepaso doctor usepaso doctor usepaso doctor - name: list_issues permission: read # Safe. No data changes. - name: resolve_issue permission: write # Modifies data. Requires caution. - name: delete_issue permission: admin # High risk. Always requires consent. - name: list_issues permission: read # Safe. No data changes. - name: resolve_issue permission: write # Modifies data. Requires caution. - name: delete_issue permission: admin # High risk. Always requires consent. - name: list_issues permission: read # Safe. No data changes. - name: resolve_issue permission: write # Modifies data. Requires caution. - name: delete_issue permission: admin # High risk. Always requires consent. - name: delete_issue permission: admin consent_required: true - name: delete_issue permission: admin consent_required: true - name: delete_issue permission: admin consent_required: true constraints: - max_per_hour: 100 description: Deletion is rate-limited constraints: - max_per_hour: 100 description: Deletion is rate-limited constraints: - max_per_hour: 100 description: Deletion is rate-limited permissions: read: - list_issues write: - resolve_issue forbidden: - drop_database permissions: read: - list_issues write: - resolve_issue forbidden: - drop_database permissions: read: - list_issues write: - resolve_issue forbidden: - drop_database usepaso connect claude-desktop usepaso connect claude-desktop usepaso connect claude-desktop usepaso connect cursor usepaso connect cursor usepaso connect cursor usepaso connect vscode usepaso connect vscode usepaso connect vscode usepaso connect windsurf usepaso connect windsurf usepaso connect windsurf usepaso init --from-openapi ./openapi.json usepaso init --from-openapi ./openapi.json usepaso init --from-openapi ./openapi.json usepaso validate --strict --json usepaso validate --strict --json usepaso validate --strict --json npx usepaso init --name "YourAPI" npx usepaso init --name "YourAPI" npx usepaso init --name "YourAPI" - Exposes tools. Each tool has a name, description, and typed inputs. The agent reads these to decide what to call. - Handles requests. When the agent calls a tool, the server validates inputs, constructs the HTTP request, and forwards it to your API. - Returns responses. The API response goes back through the MCP protocol to the agent. - Add pagination. Don't let agents fetch unbounded lists. - Use constraints. Rate limits prevent agents from overwhelming your API. - Keep declarations focused. 6 well-chosen capabilities serve agents better than 50. - What is a paso Declaration?. field-by-field YAML breakdown - How to Create an MCP Server. manual vs. paso, side by side - paso vs Writing MCP Servers by Hand. line-by-line code comparison - MCP vs REST vs GraphQL. what changes when AI is the client - Common MCP Server Errors. seven errors and how to fix them - Five Ways to Test Before You Ship. the complete testing workflow - MCP Server Performance. benchmarks and optimization - What Happens When an Agent Calls DELETE. permissions and safety - Connect Stripe to Claude Desktop. end-to-end API integration - OpenAPI to MCP in 60 Seconds. import from existing specs - How to Make Your API Work with Claude. connecting to Claude Desktop - paso Works the Same in Python. Python support - Why We Built paso. the motivation