TL;DR
- Most MCP servers are built and run locally over stdio, and browser-based testers can't reach them.
- Calling your tools by hand proves the server works. Only a real model proves it's usable: the right tool, valid arguments, and knowing when to stop.
- This guide builds a tiny server, connects it with MCP Playground Desktop, calls its tools, then hands it to Claude, GPT and Gemini to see where it breaks.
The tight loop is the point: edit a tool description, reconnect, ask again. No deploy, no tunnel, no restarting Claude Desktop.
Why local MCP servers are hard to test
Claude Desktop, Cursor, VS Code and Claude Code mostly launch MCP servers over stdio: the client spawns your server as a child process and exchanges JSON-RPC over its stdin and stdout. There's no URL.
That rules out most tooling:
-
Browser-based testers can't spawn a process, and browsers block HTTPS pages from calling
http://localhost. They only work once your server has a public URL. - Your real client hides the details. When Claude Desktop can't start a server, you get a failed status and a log file to dig through.
-
The official MCP Inspector handles stdio well, but you launch it with
npxeach time, retype the command and env vars, and there's no model in the loop.
What you want while building is a client that runs on your machine, remembers your servers, shows every message, and lets a model drive your tools.
A small server to follow along with
Here's a notes server with three tools. The descriptions are deliberately lazy, the way first drafts usually are. We'll fix them later, once a model shows us why.
TypeScript
mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
server.ts:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const notes = new Map<string, { title: string; body: string }>([
['1', { title: 'Launch checklist', body: 'Sign the Mac build, write release notes' }],
['2', { title: 'Bug: login loop', body: 'Token refresh fails after 30 minutes' }],
]);
const server = new McpServer({ name: 'notes', version: '0.1.0' });
server.registerTool(
'search',
{ description: 'Search', inputSchema: { q: z.string() } },
async ({ q }) => {
const hits = [...notes].filter(([, n]) => `${n.title} ${n.body}`.toLowerCase().includes(q.toLowerCase()));
return { content: [{ type: 'text', text: JSON.stringify(hits.map(([id, n]) => ({ id, ...n }))) }] };
}
);
server.registerTool(
'add',
{ description: 'Add', inputSchema: { title: z.string(), body: z.string() } },
async ({ title, body }) => {
const id = String(notes.size + 1);
notes.set(id, { title, body });
return { content: [{ type: 'text', text: `ok ${id}` }] };
}
);
server.registerTool(
'delete',
{ description: 'Delete', inputSchema: { id: z.string() } },
async ({ id }) => {
notes.delete(id);
return { content: [{ type: 'text', text: 'ok' }] };
}
);
// Logs go to stderr: on stdio, stdout is reserved for JSON-RPC.
console.error('notes server ready');
await server.connect(new StdioServerTransport());
The command to start it: npx tsx server.ts
Python
# server.py (mcp 2.x; on 1.x it's: from mcp.server.fastmcp import FastMCP)
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("notes")
notes = {"1": {"title": "Launch checklist", "body": "Sign the Mac build, write release notes"}}
@mcp.tool()
def search(q: str) -> list[dict]:
"""Search"""
return [{"id": i, **n} for i, n in notes.items() if q.lower() in (n["title"] + n["body"]).lower()]
if __name__ == "__main__":
mcp.run() # stdio by default
The command to start it: uv run --with mcp server.py
The workflow: connect, call, read the log
- Install the app. Download MCP Playground Desktop for Mac (Apple Silicon or Intel) or Windows, and sign in with your browser. A free account gets a 7-day trial.
-
Add the server. Choose stdio, enter
npx tsx server.tsas the command, and set the working directory to yournotes-mcpfolder. Add any env vars your server reads, like API keys. -
Connect. The app spawns the process, runs the
initializehandshake and lists the three tools. Anything the server prints to stderr (notes server ready) shows up in the log. -
Call a tool by hand. Pick
search, typebuginto the form generated from its schema, and run it. The result and the raw JSON-RPC request and response both appear, in order.
Already using the server in Claude Desktop, Cursor, VS Code, Windsurf or Claude Code? Import it instead of retyping: the app reads their MCP config files.
At this point everything "works". Each tool returns what you'd expect. This is where most testing stops, and where most bugs are still waiting.
Testing it with AI models
Open Agent Studio in the app, pick a model, and give it a real task:
Find my note about the login bug, then add a follow-up note saying it's fixed in 0.1.1.
The model sees only your tool names, descriptions and schemas. Every call it makes runs against your local server, and the app shows each one: the tool picked, the arguments sent, and the exact result.
What to look for
-
Does it pick the right tool? With
search/add/deletedescribed as just "Search", "Add" and "Delete", models guess. Some search for "login bug fixed" instead of "login". -
Are the arguments valid? A field called
qwith no description invites anything. Watch for empty strings, whole sentences, or invented fields. -
Does it understand the result?
ok 3tells a model almost nothing. It may search again to check whether the note exists. - Does it stop? A model calling the same tool in a loop usually means the result never says the job is done.
- Does it ask before deleting? Tools with no annotations look harmless. Mark destructive ones so clients can ask first.
The fix: descriptions written for a model
server.registerTool(
'search_notes',
{
description:
"Search the user's saved notes by keyword. Returns up to 10 matching notes as {id, title, body}. " +
'Search before adding a note, to avoid duplicates.',
inputSchema: { query: z.string().describe('One or two keywords, e.g. "login" or "release"') },
annotations: { readOnlyHint: true },
},
async ({ query }) => { /* same as before */ }
);
server.registerTool(
'delete_note',
{
description: 'Permanently delete one note by its id. Only use when the user explicitly asks to delete.',
inputSchema: { id: z.string().describe('The id from search_notes') },
annotations: { destructiveHint: true },
},
async ({ id }) => ({ content: [{ type: 'text', text: `Deleted note ${id}.` }] })
);
Reconnect and ask the same question. The difference is usually obvious on the first run: one targeted search, one add with a sensible title, a clear final answer.
With destructiveHint: true, Agent Studio holds delete_note calls for your approval before they run, so a confused model can't wipe your test data.
Compare models before you ship
Run the same prompt on a few of the 80+ models: Claude, GPT, Gemini, Grok, DeepSeek, Qwen. A description that works for one model and confuses another is still a bug. Your users will connect your server to whatever client and model they already use.
You can also connect several servers at once, for example your local server next to a remote one, and check that an agent picks the right tool from the right server.
Common local errors (and fixes)
spawn npx ENOENT
The client couldn't find npx. Apps launched from the Dock or Start menu get a shorter PATH than your terminal, so Node installed via nvm, fnm, Volta or Homebrew goes missing. MCP Playground Desktop loads your login shell's PATH; in other clients, use the full path from which npx.
Parse error right after connecting
Something wrote to stdout. On stdio, stdout is the protocol. Use console.error in Node or print(..., file=sys.stderr) in Python.
The server exits immediately
Usually a missing env var or the wrong working directory. Set both on the connection, then read the server's stderr in the log.
Connection refused on localhost
For HTTP servers: the server isn't running, or listens on a different port or path. Check the full URL, including /mcp (Streamable HTTP) or /sse (legacy SSE).
Wrapping up
The loop that catches the most bugs is short:
- Run the server locally over stdio.
- Call every tool by hand and read the protocol log.
- Hand it to a few models with a real task.
- Fix names, descriptions, schemas and results. Reconnect and repeat.
MCP Playground Desktop runs that whole loop on your machine, for macOS 13+ and Windows 10/11, with a free 7-day trial: mcpplaygroundonline.com/local-mcp-server-testing
Once your server is deployed, the free browser tools on mcpplaygroundonline.com test any public MCP URL with no install.
What breaks most often in your MCP servers? Let me know in the comments.


Top comments (0)