跳到正文
DEV Community· NextCTL·· 3 小時前AI 評分67

如何用 TypeScript 安全地建立首個 MCP Server,向 AI 智能體開放內部工具

Building Your First MCP Server: Exposing Internal Tools to AI Agents Safely (TypeScript)

AI 導讀

NextCTL 工程團隊示範如何用 TypeScript 建立 MCP Server,安全開放訂單查詢和退款申請兩項內部工具。設計包括嚴格輸入結構、預設唯讀,以及只建立待審批申請的退款操作;每次工具呼叫均寫入稽核記錄。遠端採用 Streamable HTTP 時,教程要求加入 OAuth 2.1 驗證、Token 受眾檢查,以及按工具和用戶授權。

正文

The Model Context Protocol (MCP) has become the default way to connect AI assistants and agents to tools and data. Instead of writing a custom integration for every model and every client, you write one MCP server, and any MCP-capable client (desktop assistants, IDEs, agent frameworks) can discover and call its tools.

That convenience has a flip side: an MCP server is an API whose caller is a language model, which can be confused, manipulated by text it reads, or simply wrong. So "it works" is not the bar. The bar is "it works, and the worst thing a misbehaving agent can do through it is small and visible."

In this tutorial we will build a small MCP server in TypeScript that exposes two internal capabilities, an order lookup and a refund request, and apply safety patterns from the start: strict input schemas, read-only by default, write actions that only create pending requests, and an audit log of every call.

What an MCP Server Actually Exposes

An MCP server can offer three kinds of things to a client:

  • Tools: functions the model can call (with a JSON Schema for inputs, and optionally outputs).
  • Resources: read-only data the client can load into context, addressed by URI.
  • Prompts: reusable prompt templates the user can invoke.

Communication runs over JSON-RPC, using one of two standard transports: stdio (the client launches your server as a local subprocess) or Streamable HTTP (your server runs as a remote service). We will start with stdio, which is the fastest way to develop and test, then cover what changes for remote deployment.

Project Setup

mkdir internal-tools-mcp && cd internal-tools-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir build --rootDir src --strict

Add "type": "module" to package.json. Then create src/index.ts.

A Minimal, Read-Only Tool

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { audit } from "./audit.js";
// backend.ts wraps your existing order service or database
import { getOrder, findOpenRefundRequest, createRefundRequest } from "./backend.js";

const server = new McpServer({ name: "internal-tools", version: "0.1.0" });

server.registerTool(
  "lookup_order",
  {
    title: "Look up an order",
    description:
      "Returns status, items and totals for one order. Read-only. " +
      "Use when the user asks about a specific order ID.",
    inputSchema: {
      orderId: z
        .string()
        .regex(/^ORD-\d{6}$/, "Order IDs look like ORD-123456"),
    },
    outputSchema: {
      orderId: z.string(),
      status: z.enum(["pending", "shipped", "delivered", "cancelled"]),
      totalCents: z.number().int(),
      itemCount: z.number().int(),
    },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ orderId }) => {
    const order = await getOrder(orderId);
    if (!order) {
      audit("lookup_order", { orderId }, "not_found");
      return {
        isError: true,
        content: [{ type: "text", text: `No order found with ID ${orderId}.` }],
      };
    }
    // Return only the fields the agent needs, never the raw DB row.
    const result = {
      orderId: order.id,
      status: order.status,
      totalCents: order.totalCents,
      itemCount: order.items.length,
    };
    audit("lookup_order", { orderId }, "ok");
    return {
      structuredContent: result,
      content: [{ type: "text", text: JSON.stringify(result) }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("internal-tools MCP server running on stdio");

A few deliberate choices here:

  1. The schema is a contract, not a suggestion. The regex on orderId means the model cannot pass *, a SQL fragment or another customer's email. The SDK validates inputs against the schema before your handler runs.
  2. Output is projected. We return four fields, not the whole order record with addresses and payment metadata. Whatever you return goes into the model's context, and from there potentially to the user or to other tools.
  3. Errors are tool results, not exceptions. Returning isError: true with a readable message lets the model recover ("that order doesn't exist, can you check the ID?") rather than failing the whole turn.
  4. Annotations describe behavior. readOnlyHint tells clients this tool does not modify anything, which some clients use to skip confirmation prompts. Treat annotations as hints for the client, never as your security boundary.
  5. Logging goes to stderr. With the stdio transport, stdout is the protocol channel. A stray console.log corrupts the JSON-RPC stream. This is the single most common first-day bug.

The Audit Log

Every tool call should leave a record. Keep it structured so you can query it later.

// src/audit.ts
import { appendFileSync } from "node:fs";

const REDACT = new Set(["email", "phone", "cardLast4"]);

export function audit(tool: string, args: Record<string, unknown>, outcome: string) {
  const safeArgs = Object.fromEntries(
    Object.entries(args).map(([k, v]) => [k, REDACT.has(k) ? "[redacted]" : v])
  );
  const entry = {
    ts: new Date().toISOString(),
    tool,
    args: safeArgs,
    outcome,
  };
  appendFileSync(process.env.AUDIT_LOG ?? "audit.jsonl", JSON.stringify(entry) + "\n");
}

In production you would ship these entries to your logging platform and attach a request or session ID and the authenticated user, but even a local JSONL file is enough to answer "what did the agent do yesterday?"

A Write Tool That Can't Do Damage on Its Own

Now the interesting part. We want the agent to help with refunds, but we do not want a model issuing refunds directly. The pattern: the tool creates a pending request that a person approves in your existing system. Add this registration above the transport lines in index.ts:

const MAX_REFUND_CENTS = 20_000; // hard ceiling enforced server-side

server.registerTool(
  "request_refund",
  {
    title: "Request a refund (requires human approval)",
    description:
      "Creates a PENDING refund request for staff review. Does not move money. " +
      "Only use after confirming the order ID and reason with the user.",
    inputSchema: {
      orderId: z.string().regex(/^ORD-\d{6}$/),
      amountCents: z.number().int().positive().max(MAX_REFUND_CENTS),
      reason: z.enum(["damaged", "not_received", "wrong_item", "other"]),
      note: z.string().max(500).optional(),
    },
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
    },
  },
  async ({ orderId, amountCents, reason, note }) => {
    const order = await getOrder(orderId);
    if (!order) {
      return { isError: true, content: [{ type: "text", text: "Order not found." }] };
    }
    if (amountCents > order.totalCents) {
      audit("request_refund", { orderId, amountCents }, "rejected_over_total");
      return {
        isError: true,
        content: [{ type: "text", text: "Refund exceeds the order total." }],
      };
    }
    // Idempotency: one open request per order.
    const existing = await findOpenRefundRequest(orderId);
    if (existing) {
      return {
        content: [{ type: "text", text: `A refund request already exists: ${existing.id} (${existing.status}).` }],
      };
    }
    const req = await createRefundRequest({ orderId, amountCents, reason, note, source: "mcp-agent" });
    audit("request_refund", { orderId, amountCents, reason }, `created:${req.id}`);
    return {
      content: [{
        type: "text",
        text: `Refund request ${req.id} created and sent to staff for approval. No money has moved yet.`,
      }],
    };
  }
);

Notice what is enforced in code rather than in the prompt: the maximum amount, the "not more than the order total" rule, idempotency and the fact that money never moves. The tool description helps the model behave well; the handler guarantees that bad behavior is harmless. Also note the response tells the model plainly that nothing has been paid, so it does not tell the user "your refund is done."

Testing with the MCP Inspector

Build and launch the official inspector against your server:

npx tsc
npx @modelcontextprotocol/inspector node build/index.js

The inspector lists your tools, shows their schemas and lets you call them by hand. Before connecting a real model, try the inputs a confused or manipulated agent might send: wrong ID formats, negative amounts, amounts above the limit, the same refund twice, extremely long notes. Every one should fail cleanly or be harmless.

Then connect a client. Most desktop MCP clients take a config entry like:

{
  "mcpServers": {
    "internal-tools": {
      "command": "node",
      "args": ["/absolute/path/to/build/index.js"],
      "env": { "AUDIT_LOG": "/absolute/path/to/audit.jsonl" }
    }
  }
}

Going Remote: What Changes

A stdio server runs with the permissions of whoever launched it. Once you deploy over Streamable HTTP for a team, you need real authentication and authorization:

  • Authenticate every request. The MCP specification defines an OAuth 2.1-based authorization flow for HTTP transports, in which your server acts as a resource server and validates access tokens issued by your identity provider.
  • Validate the token audience. Accept only tokens issued for your MCP server, and do not forward the client's token to downstream APIs (the spec explicitly prohibits this "token passthrough"). Use your own service credentials or a proper token exchange for downstream calls.
  • Authorize per tool and per user. Map scopes or roles to tools. A support agent's session might get lookup_order and request_refund; an analyst's session might get only read tools.
  • Scope data to the caller. lookup_order should only return orders the authenticated user is allowed to see. The agent inherits the user's permissions, never more.
  • Rate-limit and time out. Agents can loop. Cap calls per session and per minute, and set timeouts on backend calls.
  • Validate the Origin header and bind locally when testing, to avoid DNS-rebinding attacks against local HTTP servers.

Security Pitfalls Specific to MCP

  • Prompt injection through tool results. If a tool returns user-generated text (ticket bodies, emails, web pages), that text may contain instructions aimed at the model. Never let a tool's output be the only thing standing between the model and a harmful action; enforce limits in handlers.
  • Tool poisoning. Tool descriptions are read by the model. Only install servers you trust, review descriptions, and pin versions of third-party servers.
  • Over-broad tools. A run_sql or call_any_api tool turns your server into a remote shell for the model. Prefer many narrow tools over one general one.
  • Leaky outputs. Return the minimum. Redact personal data the agent does not need.
  • Secrets in config. Keep credentials in environment variables or a secrets manager, never in tool descriptions or responses.

Checklist Before You Ship

  • [ ] Every tool has a strict input schema (formats, enums, max lengths, numeric bounds).
  • [ ] Outputs are projected to only the fields the agent needs.
  • [ ] Read-only by default; each write tool reviewed individually.
  • [ ] Consequential actions create pending requests or require approval.
  • [ ] Limits and business rules enforced in the handler, not the prompt.
  • [ ] Every call is audit-logged with user, tool, arguments (redacted) and outcome.
  • [ ] Remote deployments use OAuth, audience checks and per-tool authorization.
  • [ ] Rate limits and timeouts in place.
  • [ ] Tested with hostile and malformed inputs in the inspector.

Wrapping Up

An MCP server is small, but it sits on the boundary between a probabilistic system and your real data. Design it like any public API, with one extra assumption: the caller might be persuaded to do something you did not intend. Narrow tools, enforced limits, pending-by-default writes and a good audit log will get you most of the way to an agent integration you can trust.


Written by the engineering team at NextCTL LLC, an AI-native software engineering and business automation company that builds AI agents, MCP integrations and custom software for teams in the US, Canada, the UK and Europe. More at nextctl.com.

來源:DEV Community · dev.to