Why your first MCP server doesn't show any tools (and a starter that fixes it)

I've built MCP servers for LinkedIn, Twitter/X, Medium and Kaggle. Most of what went wrong with them wasn't the API logic. It was small plumbing mistakes that the usual starters don't protect you from. So I pulled the fixes into one small template: mcp-server-starter (TypeScript, MIT).

Why most MCP starters break

The common failures look like this:

  • The server "connects" but the client shows no tools, or you get JSON parse errors.
  • One bad tool call kills the whole server.
  • A missing API key crashes on startup and leaves an unreadable stack trace in the client logs.
  • An API key ends up printed in logs or committed in a .env.
  • Nothing runs in CI, so a broken build only shows up when a user tries it.

1. With stdio, stdout belongs to the protocol

With the stdio transport, every byte on stdout has to be a JSON-RPC message. One console.log("starting...") puts plain text into that stream. Depending on the client, you get parse errors, an empty tool list, or a connection that drops without explanation.

The starter handles this three ways:

  • src/logger.ts writes everything to stderr, which Claude Desktop and Cursor show as server logs. LOG_LEVEL can be debug, info, warn or error.
  • ESLint's no-console rule allows only console.error, so a stray console.log fails lint and CI.
  • Secrets are never printed. Startup logs only show a masked key like sk-t…7890.
// eslint.config.js rules: { // stdout is reserved for the MCP protocol. Use the logger (stderr) instead. 'no-console': ['error', { allow: ['error'] }] } 

2. Every tool input is validated with zod

Tools use the SDK 1.x McpServer.registerTool API with a zod inputSchema. The SDK validates arguments before your handler runs, and the .describe() text is what the model reads when it fills in arguments.

export const exampleSearchInput = { query: z.string().trim().min(1, 'query must not be empty').max(200).describe('Text to search for'), limit: z.number().int().min(1).max(50).default(10).describe('Maximum number of results (1-50)') }; 

3. Failures become tool errors, not crashes

Each handler runs inside runTool(). Anything it throws becomes an MCP result with isError: true and a short message the model can act on. The full details, including the stack, go to stderr. One failed upstream call never takes the server down.

4. Safe API-key handling

src/config.ts reads the key lazily. If EXAMPLE_API_KEY is missing, the server still starts and lists its tools. Only the tool that needs the key returns a clear ConfigError telling you to add it to the env block of your MCP client config. Keys live in that env block, or in a local .env that's already in .gitignore. maskSecret() lets you show that a key is set without revealing it.

5. CI from day one

A GitHub Actions workflow runs npm ci, typecheck, lint and build on Node 20 and 22 for every push and PR. Because of the lint rule, CI also catches the stdout mistake.

Try it

git clone https://github.com/Dishant27/mcp-server-starter.git my-mcp-server cd my-mcp-server npm install && npm run build EXAMPLE_API_KEY=your-key npm run inspect # opens the MCP Inspector 

Adding a tool takes three steps. Copy src/tools/exampleSearch.ts, register it in src/tools/index.ts, and rebuild. The README has a copy-paste example and Claude Desktop and Cursor configs.

Repo: https://github.com/Dishant27/mcp-server-starter. Issues and PRs are welcome, especially from anyone who has hit a different "why doesn't my MCP server work" problem.