跪拜 Guibai
← Back to the summary

MCP in 15 Minutes: Wrap Your Backend API into a Reusable AI Tool

MCP Development Practice: Connect Your Own Backend API to Agents

This is also a practical, easy-to-understand version. It omits the tedious engineering pitfalls, Node/TS module mechanics details, and unit test source code, keeps all core design ideas, multimodal captcha login scheme, and out-of-the-box code. In about 15 minutes, you can easily understand MCP and turn your backend API into an on-demand AI tool.

image.png


1. Why learn MCP when Function Calling exists?

Anyone who has built agents or used AI coding tools knows a pain point: every time you switch AI clients, you have to reconnect the same set of APIs from scratch.

Suppose you built a bookmark site (call it linkbox) and you regularly stash good articles and videos there. You want AI to manage bookmarks: "Help me look at the latest bookmarked video in Read Later, and copy it to the Frontend favorites."

Here is a core engineering experience: as long as your system has an API, directly wrap that API with MCP — never let the Agent simulate a browser clicking around. A page's DOM structure can break with a style change, while a backend API's input and output are always deterministic, fast, and token-efficient.

graph LR
    U["You: Help me see the latest bookmarked video in Read Later"] --> C["AI client<br/>Cursor / Claude Desktop"]
    C <-->|"MCP standard protocol (stdio)"| S["Your MCP Server<br/>(less than 100 lines of TS)"]
    S <-->|"HTTP request + Token"| A["Your backend API<br/>(linkbox bookmark site)"]

2. Core understanding: what does an MCP Server actually do?

Many people get dizzy reading the MCP documentation for the first time because of the various concepts. In fact, the official documentation is clear that an MCP Server can provide three types of capabilities:

Capability Type Plain Understanding Who Decides When to Use?
Tools (the hero of this article) Functions AI can proactively call (e.g., "look up bookmark", "copy bookmark") The LLM decides itself (write operations usually require user approval)
Resources Read-only reference material (e.g., log files, API docs) User or client mounts proactively
Prompts Predefined shortcut command templates User manually selects to trigger

To implement "let AI call my own API", you only need Tools.

A minimal local MCP Server project structure needs only 3 files, each handling one concern:

linkbox-mcp/
├── src/
│   ├── index.ts    # 1. Entry: create McpServer, attach stdio transport (only 10 lines)
│   ├── client.ts   # 2. API wrapper: unified fetch, add Authorization header, handle errors
│   └── tools.ts    # 3. Tool definitions: write tool name, description, parameter schema, call API and return data
├── package.json    # Dependencies only @modelcontextprotocol/sdk and zod, plus "type": "module"
└── tsconfig.json   # Set "target": "ES2022", "module": "Node16", "outDir": "dist"

Newbie configuration tip: because you'll use top-level await and Node native ESM modules, add "type": "module" to package.json; after enabling "module": "Node16" in tsconfig.json, local relative imports in TS files need the .js suffix (e.g., import { registerTools } from "./tools.js").

First look at the entry file src/index.ts, which is less than 10 lines:

// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools.js";

const server = new McpServer({ name: "linkbox", version: "0.2.0" });
registerTools(server);

// Use standard input/output (stdio) to communicate with the AI client
await server.connect(new StdioServerTransport());

What is stdio? Why is it preferred for local tools? stdio stands for Standard Input / Output. Normally, when writing a web service you listen on a port (e.g., :3000) and send HTTP requests. But any command-line process (like node dist/index.js) in the operating system natively has three default pipes:

  • stdin (standard input): the pipe that receives external input data;
  • stdout (standard output): the pipe for outputting normal results (where console.log goes);
  • stderr (standard error): a separate pipe for errors and debug logs (corresponding to console.error).

With stdio mode, you don't need to open a port, configure a domain, or deploy a server! Cursor or Claude Desktop will launch your script as a local child process, push JSON-RPC requests into stdin, read results from stdout, just like connecting a dedicated local speaking tube — ready when you call it.


3. Turning your API into a useful AI tool: four golden rules

The AI cannot see your business code; it can only see three things: tool name, tool description, and parameter schema (including each parameter's .describe()).

If your API documentation is wrong, a colleague will ask you; if your tool description is wrong, the AI will silently pass wrong parameters or retry randomly. In practice, when wrapping linkbox's four bookmark tools, the four most effective design rules are:

Golden Rule Negative Example (directly copying backend interface) Positive Practice (AI-friendly design) Why?
1. Give concrete examples in parameter descriptions z.string().describe("collection name") z.string().describe("collection name, e.g., Read Later, Frontend") A real example beats ten abstract definitions in making the model fill it correctly once
2. Hide the API's "dialect" in code Let AI pass q: "title=~MCP" or from: "Read Later/1024" Let AI pass only keyword q: "MCP" or separate collection + id, and concatenate in code Let AI fill the value that matches natural language intuition; all format stitching stays in code
3. Slim down return data Pass all 20 fields from the backend (including internal IDs, 0001-01-01 fake timestamps, byte counts) to AI as is Keep only 7 core fields, flatten tags into a string array, convert bytes to 3.3 (MB) Every character returned by a tool occupies context and costs tokens; removing noise not only saves money but prevents AI from being misled by fake timestamps
4. Errors should tell AI what to do next Directly throw 401 Unauthorized (AI keeps trying with different parameters) Return isError: true with content: "token expired, first call get_login_captcha to get captcha, then call login to log in" Turn a dead error into actionable instructions, and the model can understand and complete the recovery flow itself

4. Ready to use: core code for request wrapper and tool definitions

Following the four rules above, here is how the core code looks.

4.1 Unified request layer (src/client.ts)

Responsible for building URLs, automatically adding Authorization header, and translating HTTP 401/403 to clear Chinese messages:

// src/client.ts
const BASE = process.env.LINKBOX_BASE ?? "http://localhost:8080";
let sessionToken = "";

// Automatically add Bearer prefix, compatible with both the full string copied from browser and the raw JWT from login API
 export function setSessionToken(token: string) {
  const value = token.trim();
  sessionToken = !value ? "" : value.startsWith("Bearer ") ? value : `Bearer ${value}`;
}
setSessionToken(process.env.LINKBOX_TOKEN ?? "");

export async function linkboxApi<T = any>(
  path: string,
  req: { method?: string; query?: Record<string, any>; body?: unknown } = {},
): Promise<T> {
  const method = req.method ?? (req.body !== undefined ? "POST" : "GET");
  const headers: Record<string, string> = {};

  // Warning: auth endpoints (get captcha, login) are themselves used to get a new token; never carry an expired old token
  const skipAuth = path.startsWith("/auth/");
  if (!skipAuth && sessionToken) headers.Authorization = sessionToken;

  const queryStr = req.query
    ? `?${new URLSearchParams(Object.entries(req.query).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}`
    : "";

  const res = await fetch(`${BASE}${path}${queryStr}`, {
    method,
    headers: req.body !== undefined ? { ...headers, "Content-Type": "application/json" } : headers,
    body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
  });

  if (res.status === 401 || res.status === 403) {
    if (skipAuth) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
    throw new Error("token invalid or expired");
  }
  if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);

  const text = await res.text();
  return (text ? JSON.parse(text) : undefined) as T;
}

export const linkboxOk = (data: unknown) => ({
  content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});

4.2 Tool definition layer (src/tools.ts excerpt)

Here are the two most representative tools — query and slim bookmarks (read operation) and cross-collection copy bookmark (write operation):

// src/tools.ts (excerpt)
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { linkboxApi, linkboxOk } from "./client.js";

// Convert exceptions into isError results with operation guidance, leading the AI to self-recover
function asError(err: unknown) {
  const text = err instanceof Error ? err.message : String(err);
  return {
    isError: true,
    content: [{
      type: "text" as const,
      text: text.includes("token invalid")
        ? "token invalid or expired, first call get_login_captcha to get the captcha, compute the result, call login to re-login, then retry the previous operation"
        : text,
    }],
  };
}

export function registerTools(server: McpServer) {
  // 1. Read tool: list bookmarks, with field trimming and unit conversion before return
  server.registerTool(
    "list_bookmarks",
    {
      description: "List all bookmarks in a collection, sorted by collection time from newest to oldest (first is the latest)",
      inputSchema: {
        collection_name: z.string().describe("Collection name, e.g., Read Later"),
        page: z.number().int().min(1).default(1),
        page_size: z.number().int().min(1).max(100).default(20),
      },
      // readOnlyHint: true tells the client this is pure query (read-only, no data change), client can auto-approve without popup
      annotations: { readOnlyHint: true },
    },
    async ({ collection_name, page, page_size }) => {
      try {
        const { data } = await linkboxApi("/api/linkbox/bookmarks-page-list", {
          query: { collection_name, page, page_size },
        });
        // Keep only the 7 fields the AI needs to answer questions, saving tokens
        const items = data.map((b: any) => ({
          id: b.id,
          title: b.title,
          url: b.url,
          tags: b.tags.map((t: any) => t.name), // flatten array of objects to ["MCP", "Frontend"]
          created_time: b.created_time,
          visited_time: b.visited_time,
          snapshot_mb: +(Number(b.snapshot_size) / 1024 / 1024).toFixed(1), // bytes to MB
        }));
        return linkboxOk(items);
      } catch (err) {
        return asError(err);
      }
    },
  );

  // 2. Write tool: split compound parameters, use ID for precise targeting to prevent misoperation
  server.registerTool(
    "copy_bookmark",
    {
      description: "Copy a bookmark with the specified ID from a source collection to a target collection. This actually writes data; please confirm with the user before calling.",
      inputSchema: {
        from_collection_name: z.string().describe("Source collection name, e.g., Read Later"),
        bookmark_id: z.number().int().describe("Bookmark ID, e.g., 1024 (can be obtained from list_bookmarks result)"),
        to_collection_name: z.string().describe("Target collection name, e.g., Frontend"),
      },
      // readOnlyHint: false tells the client that this tool actually modifies/writes data, prompting a user confirmation popup before invoking
      annotations: { readOnlyHint: false },
    },
    async ({ from_collection_name, bookmark_id, to_collection_name }) => {
      try {
        // Backend expects "collection_name/bookmark_id", concatenate in code, don't let AI do it
        const res = await linkboxApi("/api/linkbox/bookmarks-copy", {
          method: "POST",
          body: {
            from: `${from_collection_name}/${bookmark_id}`,
            collection_name: to_collection_name,
          },
        });
        return linkboxOk(res);
      } catch (err) {
        return asError(err);
      }
    },
  );
}

5. Advanced practice: Facing image captcha? Let multimodal AI read and login!

The most headache-inducing part of calling your own backend API is the login state (Token) expiration.

The first version used the dumbest method: copy Bearer <JWT> from the browser F12 console into environment variables. But once the token expired, you had to reopen the webpage, log in, copy, change config, restart the service — even more tiring than just clicking bookmarks on the webpage. To hardcode auto-login, the backend template includes an arithmetic image captcha (like 3+5=?) + RSA password encryption.

Then I had an idea: captcha defends against old dead scripts, but today's large models are naturally good at reading images! And MCP tools can return not just text, but directly image content!

As long as you split login into two tools, and follow one iron rule — "AI only reads the image and does the arithmetic; account and password stay only in environment variables, never passed through AI" — you can achieve fully automatic token renewal:

sequenceDiagram
    participant AI as AI Model (multimodal)
    participant S as MCP Server (local process)
    participant B as Your Backend API
    AI->>S: 1. Call list_bookmarks, receive "token expired, please get captcha and login"
    AI->>S: 2. Call get_login_captcha()
    S->>B: GET /auth/code
    B-->>S: Return uuid + base64 captcha image
    S-->>AI: Return MCP image content (arithmetic image)
    Note over AI: 3. Model reads image, computes 3+5 = 8
    AI->>S: 4. Call login({ code: "8" })
    Note over S: Read username/password from env vars, RSA-encrypt with public key
    S->>B: POST /auth/login (username, ciphertext, code=8, uuid)
    B-->>S: Return new accessToken
    Note over S: Store in memory sessionToken
    S-->>AI: Login successful!
    AI->>S: 5. Automatically retry list_bookmarks, get bookmark results

Why must account and password never be passed as parameters to the login tool for AI to handle? Because tool parameters are not a private channel! If you let AI pass password, the password enters the LLM context, appears in the client's approval popup, and gets written into chat history logs. Put account and password in the MCP configuration's env variables, let the Server read them internally and encrypt with Node's native crypto.publicEncrypt — the AI only ever sees a captcha digit 8, completely preventing password leak.

Core implementation code:

// 1. RSA password encryption (aligned with frontend jsencrypt default PKCS#1 v1.5 padding)
import { constants, createPublicKey, publicEncrypt } from "node:crypto";

export function encryptPassword(password: string, publicKeyBase64: string): string {
  const key = createPublicKey({ key: Buffer.from(publicKeyBase64, "base64"), format: "der", type: "spki" });
  // Warning: Node defaults to OAEP padding, must explicitly use RSA_PKCS1_PADDING so backend can decrypt
  return publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(password, "utf8")).toString("base64");
}
// 2. Get captcha image + auto-login tool (src/tools.ts)
let lastCaptchaUuid = "";

// Tool 5: return captcha as MCP image for the model to see
server.registerTool(
  "get_login_captcha",
  {
    description: "Get the login captcha image (arithmetic problem). First call this tool to view the image and compute the result, then pass the result as code to call login. If unsure, ask the user, don't guess.",
    inputSchema: {},
  },
  async () => {
    try {
      const { data } = await linkboxApi<{ data: { uuid: string; img: string } }>("/auth/code");
      lastCaptchaUuid = data.uuid;
      // Strip the "data:image/...;base64," prefix, extract pure base64
      const base64Data = data.img.replace(/^data:[^;]+;base64,/, "");
      return {
        content: [
          { type: "text" as const, text: "Look at the image, compute the arithmetic result, and pass it as code to call login" },
          { type: "image" as const, mimeType: "image/png", data: base64Data },
        ],
      };
    } catch (err) {
      return asError(err);
    }
  },
);

// Tool 6: AI only passes the captcha result code; account/password read by Server from env vars
server.registerTool(
  "login",
  {
    description: "Log in using the captcha result. Must call get_login_captcha first; account and password are read from env vars by the server, no need to provide.",
    inputSchema: {
      code: z.string().describe("Captcha arithmetic result, e.g., 8"),
    },
  },
  async ({ code }) => {
    if (!lastCaptchaUuid) return asError("Please call get_login_captcha first to get the captcha");
    try {
      const res = await linkboxApi("/auth/login", {
        method: "POST",
        body: {
          username: process.env.LINKBOX_USERNAME,
          password: encryptPassword(process.env.LINKBOX_PASSWORD!, process.env.LINKBOX_PUBLIC_KEY!),
          code,
          uuid: lastCaptchaUuid,
        },
      });
      setSessionToken(res.data.accessToken);
      return linkboxOk({ ok: true, message: "Login successful, please retry the previous operation" });
    } catch (err) {
      return asError(err);
    } finally {
      lastCaptchaUuid = ""; // captcha is single-use, destroy after use
    }
  },
);

(Note: this image-based login is only for your own services and personal accounts; long-term, adding a permanent API token to your backend remains the most robust solution.)


6. Run in 3 minutes: Debug and connect to clients

6.1 Development debug: use Inspector + tsx for instant verification without compilation

If you have to run npm run build every time you change TypeScript code, then restart Cursor, it's too slow. Install tsx (npm i -D tsx) and use the official debug tool MCP Inspector (the Postman of MCP) to run source code directly.

First create a config.json in the project root (contains password; be sure to add it to .gitignore):

{
  "mcpServers": {
    "linkbox-dev": {
      "command": "node",
      "args": ["--import", "tsx", "src/index.ts"],
      "cwd": "/your-project-absolute-path/linkbox-mcp",
      "env": {
        "LINKBOX_BASE": "http://localhost:8080",
        "LINKBOX_USERNAME": "your_username",
        "LINKBOX_PASSWORD": "your_password",
        "LINKBOX_PUBLIC_KEY": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
      }
    }
  }
}

Open the visual debug panel with one command, click through each tool to test input and return:

npx @modelcontextprotocol/inspector --config config.json

6.2 Production integration: compile and attach to Agent

After testing passes, run npx tsc to compile dist/index.js, then add it to Cursor (.cursor/mcp.json) or Claude Desktop config files for daily use:

{
  "mcpServers": {
    "linkbox": {
      "type": "stdio",
      "command": "node",
      "args": ["/your-project-absolute-path/linkbox-mcp/dist/index.js"],
      "env": {
        "LINKBOX_BASE": "http://localhost:8080",
        "LINKBOX_USERNAME": "${env:LINKBOX_USERNAME}",
        "LINKBOX_PASSWORD": "${env:LINKBOX_PASSWORD}",
        "LINKBOX_PUBLIC_KEY": "${env:LINKBOX_PUBLIC_KEY}"
      }
    }
  }
}

Now, you only need to say in the chat: "Help me see what's been recently saved in Read Later, and copy the latest article about MCP to the Frontend collection", and the AI will complete the full sequence: "send request → discover not logged in → fetch captcha → read image, compute → login to get token → query bookmarks → copy bookmark".


7. 5 pitfalls to avoid before going live

Here are the pitfalls I hit in practice, condensed into 5 checklist items. Scan after writing code to save hours of debugging:

  1. Never use console.log in stdio mode: Once connected to StdioServerTransport, stdout becomes the JSON-RPC protocol communication channel! If you write console.log("debug info") in your code, the client will treat it as corrupted JSON and disconnect with an error. For logging, always use console.error() (outputs to stderr, doesn't affect the protocol).

  2. Never put tsx watch in the client config: tsx watch listens on stdin and restarts the process when it receives input, printing restart messages to stdout. This conflicts directly with MCP's stdio communication, causing the Server to restart on every request.

  3. On Windows, use node as command rather than npx directly: On Windows, npx is actually npx.cmd batch file; when Cursor / Claude Desktop launch it as a child process, they often throw spawn npx ENOENT. Using "command": "node", "args": ["--import", "tsx", "src/index.ts"] or directly running the compiled dist/index.js is most stable, and starts more than twice as fast.

  4. Keep the number of tools under 10, and use consistent naming: The model reads all tool descriptions in every conversation; the more tools, the easier to choose incorrectly. Use consistent underscore naming (e.g., list_bookmarks, search_bookmarks) and make sure descriptions clearly state whether they refer to "collection" or "bookmark" so two tools' descriptions don't collide.

  5. Always use server.registerTool() for new projects: The server.tool(name, desc, schema, fn) positional-argument style in old tutorials is marked as @deprecated in SDK v1.x and removed in SDK v2. Use the config-object style server.registerTool(), so you won't need to rework when adding annotations or upgrading versions.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

你可明白 1 likes

I've known the concept for a long time but never took action. This time, trying it out felt pretty good.