A TypeScript MCP Server for Search, Written Once and Reused Everywhere

One stdio server gives Claude Desktop, Claude Code, and any other MCP client the same set of search tools. Here's the whole thing.

Profile picture of Serply
Serply
An MCP server exposing search tools to multiple clients

The argument for MCP is boring and correct: you write the tool once and every client that speaks the protocol can use it. No re-implementing a search wrapper for each framework you try.

This is a complete stdio server in TypeScript.

Setup

npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

package.json needs "type": "module".

The server

#!/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 API_KEY = process.env.SERPLY_API_KEY;
if (!API_KEY) {
  console.error('SERPLY_API_KEY is not set');
  process.exit(1);
}

const BASE = 'https://api.serply.io/v1';

const server = new McpServer({ name: 'serply-search', version: '1.0.0' });

console.error rather than console.log for that startup failure. On a stdio server, stdout is the protocol channel — a stray console.log anywhere in your code corrupts the JSON-RPC stream and the client disconnects with an unhelpful parse error. This is the single most common way a hand-written MCP server fails.

server.tool(
  'web_search',
  'Search the live web via Google. Returns ranked results with title, URL, and a ' +
    'short snippet. Use this for current events, specific facts, niche topics, or ' +
    'anything you are not confident about from memory.',
  {
    query: z.string().describe('Keyword-style query, not a full sentence.'),
    num: z.number().int().min(1).max(100).default(10)
      .describe('How many results to return.'),
    location: z.string().length(2).optional()
      .describe('Two-letter country code (US, GB, DE, JP...) to search from.'),
  },
  async ({ query, num, location }) => {
    const headers: Record<string, string> = { 'X-Api-Key': API_KEY };
    if (location) headers['X-Proxy-Location'] = location.toUpperCase();

    const res = await fetch(
      `${BASE}/search/q=${encodeURIComponent(query)}&num=${num}`,
      { headers },
    );

    if (res.status === 429) {
      return {
        content: [{
          type: 'text' as const,
          text: 'Rate limited. Wait a few seconds and retry with a single, more ' +
                'specific query rather than several broad ones.',
        }],
        isError: true,
      };
    }
    if (!res.ok) {
      return {
        content: [{ type: 'text' as const, text: `Search failed: HTTP ${res.status}` }],
        isError: true,
      };
    }

    const body = await res.json();
    const results = body.results ?? [];

    if (results.length === 0) {
      return {
        content: [{
          type: 'text' as const,
          text: `No results for "${query}". Try broader or different keywords.`,
        }],
      };
    }

    const text = results
      .map((r: any, i: number) =>
        `${i + 1}. ${r.title}\n   ${r.link}\n   ${r.description ?? ''}`)
      .join('\n\n');

    const related = (body.related_searches ?? [])
      .map((s: any) => (typeof s === 'string' ? s : s.query ?? s.title))
      .filter(Boolean)
      .slice(0, 5);

    return {
      content: [{
        type: 'text' as const,
        text: related.length
          ? `${text}\n\nRelated searches: ${related.join(', ')}`
          : text,
      }],
    };
  },
);

Three deliberate choices here.

The query goes in the path (/search/q=...), which is how this API works — a ?q= querystring returns nothing useful.

Errors return isError: true with a plain-language recovery hint instead of throwing. A thrown exception reaches the model as a protocol-level failure it can’t act on; a text result telling it what to do next is something it can actually follow.

Zero results returns a normal, non-error response saying so. An agent that gets an error for “no results” will often retry the identical query.

News

server.tool(
  'news_search',
  'Search recent news articles. Returns headline, link, summary, source, and ' +
    'publication date. Use when recency matters or the question is about events.',
  { query: z.string().describe('Topic, company, or person to find coverage of.') },
  async ({ query }) => {
    const res = await fetch(`${BASE}/news/q=${encodeURIComponent(query)}`, {
      headers: { 'X-Api-Key': API_KEY },
    });
    if (!res.ok) {
      return {
        content: [{ type: 'text' as const, text: `News search failed: ${res.status}` }],
        isError: true,
      };
    }

    const body = await res.json();
    const entries = body?.feed?.entries ?? [];

    if (entries.length === 0) {
      return {
        content: [{ type: 'text' as const, text: `No recent coverage of "${query}".` }],
      };
    }

    return {
      content: [{
        type: 'text' as const,
        text: entries
          .map((e: any) =>
            `${e.title}\n  ${e.source ?? 'unknown source'} — ${e.published ?? 'no date'}\n` +
            `  ${e.link}\n  ${e.summary ?? ''}`)
          .join('\n\n'),
      }],
    };
  },
);

The news endpoint returns articles under feed.entries, not results. Formatting the date and source on their own line is worth the characters — it’s what lets the model reason about recency instead of treating all coverage as equally current.

Reading pages

server.tool(
  'read_page',
  'Fetch the full text of a web page as markdown. Use after web_search when a ' +
    'snippet is not enough. More expensive than a search — read the 2-3 most ' +
    'promising URLs, not everything.',
  {
    url: z.string().url().describe('Absolute URL, normally from a search result.'),
    max_chars: z.number().int().min(1000).max(50_000).default(15_000)
      .describe('Truncation limit for the returned text.'),
  },
  async ({ url, max_chars }) => {
    const res = await fetch(`${BASE}/request`, {
      method: 'POST',
      headers: { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, response_type: 'markdown' }),
    });
    if (!res.ok) {
      return {
        content: [{
          type: 'text' as const,
          text: `Could not fetch ${url} (HTTP ${res.status}). Try another source.`,
        }],
        isError: true,
      };
    }

    const full = await res.text();
    const text = full.slice(0, max_chars);
    const note = full.length > max_chars
      ? `\n\n[Truncated at ${max_chars} of ${full.length} characters.]`
      : '';

    return { content: [{ type: 'text' as const, text: text + note }] };
  },
);

res.text(). Markdown mode returns the content as the response body with a text/html content type; calling res.json() throws.

The truncation note is doing real work. Silent truncation produces confident summaries of documents the model only saw the first third of.

Starting up

const transport = new StdioServerTransport();
await server.connect(transport);
console.error('serply-search MCP server running on stdio');

Registering it

For Claude Desktop, in claude_desktop_config.json:

{
  "mcpServers": {
    "serply-search": {
      "command": "npx",
      "args": ["-y", "tsx", "/absolute/path/to/server.ts"],
      "env": { "SERPLY_API_KEY": "your-key" }
    }
  }
}

For Claude Code, claude mcp add with the same command, or the equivalent block in .mcp.json at your project root — checking that file in gives everyone on the team the tools without a setup document.

Absolute paths only. The client spawns your server from an unspecified working directory, and a relative path fails with a bare “server disconnected.”

Verifying it

npx @modelcontextprotocol/inspector npx -y tsx server.ts

The Inspector lists your tools, shows the exact schema the client will see, and lets you invoke each one with real arguments. Debugging a tool here takes seconds; debugging it through a chat client takes a lot longer, because a client that fails to parse your tool list just quietly shows no tools at all.