从 0 跑通 MCP:用 TypeScript 写一个 CoinGecko 工具,并接入 Inspector / Codex

如果你已经看过不少 MCP 介绍,但还没有亲手跑通过一个完整例子,这篇文章就是为你准备的。

本文不先展开复杂概念,也不一开始讨论远程部署,而是只做一件事:

从零写一个最小的 TypeScript MCP Server,接入 CoinGecko 的真实市场数据接口,并在 MCP Inspector 和 Codex 中完成验证。

这个示例最终只暴露一个工具:fetch_market_data

它的功能很简单:输入 BTCETHSOL,返回来自 CoinGecko 的实时市场价格、24 小时涨跌幅和更新时间。

但这个小工具会把 MCP 入门最关键的链路完整跑通:

  • 写一个本地 MCP Server
  • 注册一个可被宿主发现的工具
  • 接入真实第三方 API
  • 用 Inspector 做工具级调试
  • 用 Codex 验证宿主是否真的调用了工具

配套示例代码已放在 GitHub,建议边看边对照实践:


一、这篇文章解决什么问题

学 MCP 时,新手最容易卡住的通常不是“概念完全看不懂”,而是:

不知道一个最小可运行项目到底应该长什么样。

所以本文的目标很明确。读完并跑通后,你应该能完成下面几件事:

  1. 理解 MCP 在这个例子里的定位
  2. 用 TypeScript 写一个最小 MCP Server
  3. 注册一个可调用工具:fetch_market_data
  4. 让工具真实调用 CoinGecko API
  5. 用 MCP Inspector 验证工具是否可用
  6. 把它接入 Codex CLI,验证宿主是否能调用它

这不是一个只返回 hello world 的演示,而是一个有输入、有外部数据、有工具注册、有调试验证的完整入门项目。


二、先建立一个最小认知:MCP 在这里是什么

在开始写代码前,先把最关键的一点说清楚:

MCP 不是业务逻辑本身,而是一层标准化的工具协议。

在本文这个例子里,调用链路大概是这样:

Codex / ChatGPT / Inspector
    -> MCP Client
    -> 你的 MCP Server
    -> 你的 TypeScript 逻辑
    -> CoinGecko API

每一层职责不同:

  • 宿主(Host):例如 Codex、Inspector、ChatGPT,负责发现工具、调用工具、消费结果。
  • MCP Client:按 MCP 协议与 Server 通信。
  • MCP Server:把你的能力暴露成标准工具。
  • 业务逻辑:真正做事的代码,例如根据 symbol 拉取市场价格。
  • 第三方 API:例如 CoinGecko,提供真实数据来源。

所以,本文真正要实现的能力是:

输入一个币种 symbol,比如 BTC,返回对应的实时市场数据。

MCP 负责的是让这个能力能被宿主以标准方式发现和调用。

这点很重要。很多人刚接触 MCP 时,会把 MCP、Agent、业务逻辑、应用框架混在一起。更准确的理解是:

MCP 解决的是“能力如何暴露和被调用”,不是“业务本身如何实现”。

为什么先用 STDIO,而不是 HTTP

MCP 支持不同传输方式,但入门阶段我建议先用本地 STDIO

原因很直接:

  • 不需要先搭 Web 服务
  • 不需要处理端口和路由
  • 不需要 HTTPS 或公网暴露
  • 本地调试链路更短,问题更容易定位

入门阶段最重要的是先完成一个闭环:工具写出来,并且真的能被宿主调用。

所以本文的策略是:

先用 STDIO 跑通最小闭环,再考虑远程服务化。


三、从 0 搭一个最小 MCP Server

1. 准备工作

你需要准备:

  • Node.js 18 或更高版本
  • npm
  • 一个 CoinGecko API Key
  • Codex CLI(后文用于扩展验证,可选)

获取 CoinGecko API Key

CoinGecko 支持通过请求头传递 API Key:

  • Demo Key:x-cg-demo-api-key
  • Pro Key:x-cg-pro-api-key

本文用 Demo Key 即可。我们的目标是跑通 MCP 链路,不是做生产级行情服务。

2. 创建项目

先初始化一个最小项目:

mkdir mcp-coingecko-demo
cd mcp-coingecko-demo
npm init -y

安装依赖:

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

初始化 TypeScript 配置:

npx tsc --init

到这里,一个可以编写 MCP Server 的基础项目就准备好了。

3. 设计项目结构

为了让示例足够简单,同时避免把所有逻辑塞进一个文件,建议使用下面的结构:

mcp-coingecko-demo/
  package.json
  .env
  src/
    server.ts
    domain/
      market.ts

两个核心文件的职责:

  • src/server.ts:MCP Server 入口,负责注册工具、定义输入、连接传输层。
  • src/domain/market.ts:业务逻辑层,负责调用 CoinGecko API 并整理市场数据。

这样拆分有一个好处:你可以清楚地区分 MCP 协议层业务逻辑层

对入门项目来说,这不是为了“架构感”,而是为了减少理解成本。后面要增加第二个工具、替换数据源,或者写测试,都会更容易。

4. 配置环境变量

在项目根目录创建 .env

COINGECKO_API_KEY=your_demo_key_here
COINGECKO_API_PLAN=demo

两个变量分别表示:

  • COINGECKO_API_KEY:你的 CoinGecko API Key
  • COINGECKO_API_PLAN:当前使用的套餐类型,本文使用 demo

配置放进环境变量后,后续切换 Pro Key 或不同环境时,就不需要改业务代码。

5. 先写业务逻辑:获取市场数据

新建 src/domain/market.ts

import "dotenv/config";

export type MarketData = {
  id: string;
  symbol: string;
  price: number;
  currency: string;
  source: string;
  change24h?: number | null;
  lastUpdatedAt?: number | null;
};

const SYMBOL_TO_COINGECKO_ID: Record<string, string> = {
  BTC: "bitcoin",
  ETH: "ethereum",
  SOL: "solana",
};

function getCoinGeckoConfig() {
  const plan = (process.env.COINGECKO_API_PLAN || "demo").toLowerCase();
  const apiKey = process.env.COINGECKO_API_KEY;

  if (!apiKey) {
    throw new Error("Missing COINGECKO_API_KEY");
  }

  if (plan === "pro") {
    return {
      baseUrl: "https://pro-api.coingecko.com/api/v3",
      headers: {
        "x-cg-pro-api-key": apiKey,
      },
      source: "coingecko-pro",
    };
  }

  return {
    baseUrl: "https://api.coingecko.com/api/v3",
    headers: {
      "x-cg-demo-api-key": apiKey,
    },
    source: "coingecko-demo",
  };
}

function resolveCoinId(symbol: string): string {
  const normalizedSymbol = symbol.trim().toUpperCase();

  if (!normalizedSymbol) {
    throw new Error("symbol is required");
  }

  const coinId = SYMBOL_TO_COINGECKO_ID[normalizedSymbol];

  if (!coinId) {
    throw new Error(`Unsupported symbol: ${normalizedSymbol}`);
  }

  return coinId;
}

export async function fetchMarketData(symbol: string): Promise<MarketData> {
  const normalizedSymbol = symbol.trim().toUpperCase();
  const coinId = resolveCoinId(normalizedSymbol);
  const { baseUrl, headers, source } = getCoinGeckoConfig();

  const url = new URL(`${baseUrl}/simple/price`);
  url.searchParams.set("ids", coinId);
  url.searchParams.set("vs_currencies", "usd");
  url.searchParams.set("include_24hr_change", "true");
  url.searchParams.set("include_last_updated_at", "true");

  const response = await fetch(url.toString(), {
    method: "GET",
    headers,
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`CoinGecko API error: ${response.status} ${text}`);
  }

  const json = (await response.json()) as Record<
    string,
    {
      usd?: number;
      usd_24h_change?: number;
      last_updated_at?: number;
    }
  >;

  const data = json[coinId];

  if (!data || typeof data.usd !== "number") {
    throw new Error(`No price data returned for ${coinId}`);
  }

  return {
    id: coinId,
    symbol: normalizedSymbol,
    price: data.usd,
    currency: "USD",
    source,
    change24h: typeof data.usd_24h_change === "number" ? data.usd_24h_change : null,
    lastUpdatedAt:
      typeof data.last_updated_at === "number" ? data.last_updated_at : null,
  };
}

这段代码主要做了 5 件事:

  1. 读取环境变量,判断使用 Demo 还是 Pro 配置
  2. 把用户输入的 symbol 映射成 CoinGecko 识别的 coin id
  3. 调用 CoinGecko 的 /simple/price 接口
  4. 提取价格、24 小时涨跌幅和更新时间
  5. 整理成统一的 MarketData 结构返回

也就是说,这一层解决的是:

如何把用户能理解的输入,转换成第三方 API 能理解的请求,再把返回值整理成自己可控的输出。

为什么先手写 symbol 映射

这里没有一开始就支持所有币种,而是先手写了几个映射:

  • BTC -> bitcoin
  • ETH -> ethereum
  • SOL -> solana

这不是最完整的方案,但很适合入门。

原因很简单:先降低复杂度,把工具注册、API 调用、数据返回、宿主调用这条主链路跑通。至于自动支持所有 symbol、缓存 /coins/list、模糊搜索、错误纠正,都可以放到下一阶段。

技术学习里,一个很实用的原则是:

先完成最小可运行版本,再逐步扩展能力。

6. 再写 MCP Server:把能力暴露成工具

业务逻辑已经独立可用后,再写 src/server.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { fetchMarketData } from "./domain/market.js";

const server = new McpServer({
  name: "market-data-server",
  version: "1.1.0",
});

server.registerTool(
  "fetch_market_data",
  {
    description:
      "Fetch crypto market data from CoinGecko for symbols like BTC, ETH, or SOL",
    inputSchema: {
      symbol: z
        .string()
        .min(1, "symbol cannot be empty")
        .describe("Crypto asset symbol, for example BTC, ETH, or SOL"),
    },
  },
  async ({ symbol }) => {
    try {
      const result = await fetchMarketData(symbol);

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(result, null, 2),
          },
        ],
      };
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "Unknown error occurred";

      return {
        content: [
          {
            type: "text",
            text: `Failed to fetch market data: ${message}`,
          },
        ],
        isError: true,
      };
    }
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server is running...");
}

main().catch((error) => {
  console.error("Server error:", error);
  process.exit(1);
});

这段 MCP Server 代码可以拆成 4 步理解:

  1. 创建一个 MCP Server,声明服务名称和版本。
  2. 注册 fetch_market_data 工具。
  3. 用 Zod 描述输入参数,让宿主知道工具需要什么。
  4. 在工具处理函数中调用 fetchMarketData,并把结果按 MCP 工具返回格式输出。

这里最重要的不是 API 细节,而是分层关系:

MCP 层只负责“暴露工具”,真正的业务仍然在 domain/market.ts 里。

如果一开始把协议代码和业务代码都写在一个文件里,新手很容易分不清“工具注册”和“行情获取”分别在解决什么问题。

7. 启动服务,并注意 STDIO 日志

package.json 增加脚本:

{
  "type": "commonjs",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc src/server.ts --outDir dist --module commonjs --target es2020 --esModuleInterop"
  }
}

然后启动:

npm run dev

如果进程没有退出,而是保持运行、等待输入,通常说明 MCP Server 已经启动成功。

这里有一个入门时很容易忽略的细节:

对 STDIO Server 来说,“启动后挂起等待输入”是正常现象,不是程序卡死。

因为它本来就在等待宿主通过标准输入和它通信。

日志为什么要写到 stderr

如果使用 STDIO 传输,stdout 是 MCP 协议消息通道,不能随便输出普通日志。

下面这种写法有风险:

console.log("server started");

普通日志一旦混进协议输出,可能会破坏 MCP 通信。

所以在 STDIO 模式下,日志应该写到 stderr

console.error("MCP server is running...");

很多人第一次跑不通,不是 MCP 概念理解错了,而是被这类运行细节卡住了。


四、验证工具是否真的可用

1. 用 MCP Inspector 验证

在接入 Codex 前,建议先用 MCP Inspector 验证。Inspector 更适合做工具级调试,可以先排除 Server 本身的问题。

如果已经编译成 JS,可以这样启动:

npm run build
npx @modelcontextprotocol/inspector node dist/server.js

如果你习惯直接用 tsx 跑,也可以按自己的本地方式配置。

在 Inspector 阶段,重点看 3 件事:

  • Server 是否连接成功
  • 是否能看到 fetch_market_data
  • 工具是否真的可以被调用

先测试正常输入:

{
  "symbol": "BTC"
}

预期返回类似:

{
  "id": "bitcoin",
  "symbol": "BTC",
  "price": 123456,
  "currency": "USD",
  "source": "coingecko-demo",
  "change24h": 1.23,
  "lastUpdatedAt": 1712345678
}

价格会变化,这也说明你拿到的是实时数据,而不是写死的 mock。

再测试几个异常输入:

{
  "symbol": ""
}
{
  "symbol": "DOGE"
}
{}

这一步不是为了让所有输入都成功,而是确认:

  • 合法输入能正常返回
  • 非法输入不会把 Server 打崩
  • 错误信息对人来说是可理解的

一个入门项目能做到这里,已经比纯演示性质的示例更接近真实开发。

Inspector 验证截图

下面这张图是 fetch_market_data 在 MCP Inspector 中连接成功并调用成功的结果。可以看到:

  • 左侧已成功连接本地 market-data-server
  • 工具列表中出现了 fetch_market_data
  • 右侧输入 ETH 后,工具返回了来自 CoinGecko 的真实市场数据
  • Tool Result: Success 说明这条本地 MCP 调用链路已经跑通

Inspector 连接验证成功结果

2. 接入 Codex CLI 验证

Inspector 验证通过后,再接入 Codex 会更稳。因为这时你已经知道工具本身可用,剩下的问题只可能出在宿主配置或调用方式上。

创建或编辑 Codex 配置文件:

~/.codex/config.toml

加入 MCP Server 配置:

model = "gpt-5.4"

[mcp_servers.market_data]
command = "npx"
args = ["tsx", "/绝对路径/mcp-coingecko-demo/src/server.ts"]

[mcp_servers.market_data.env]
COINGECKO_API_KEY = "your_demo_key_here"
COINGECKO_API_PLAN = "demo"

注意把路径替换成你自己的绝对路径。

启动 Codex:

codex

第一次测试时,建议明确要求它调用工具:

Use the MCP tool fetch_market_data with symbol BTC. Return the raw result first, then explain it.

验证阶段不需要追求提示词自然,目标是确认宿主是否真的触发了工具调用。

如何判断 Codex 不是在“猜答案”

重点看返回结果里是否出现你在工具中定义过的字段,例如:

  • source: coingecko-demo
  • id: bitcoin
  • change24h
  • lastUpdatedAt

如果这些字段都出现了,通常就可以判断:

Codex 不是在凭语言模型常识回答,而是真的通过 MCP 调用了你的工具。

Codex 调用截图

下面这张图展示的是把 MCP Server 接入 Codex 后的调用结果。可以看到 Codex 明确调用了:

market_data.fetch_market_data({"symbol":"BTC"})

并返回了你在工具中定义的字段:

  • id: bitcoin
  • symbol: BTC
  • price
  • source: coingecko-demo
  • change24h
  • lastUpdatedAt

这说明 Codex 已经通过 MCP 工具拿到了真实返回结果。

Codex 配置执行结果


五、跑通之后,你真正掌握了什么

如果前面的步骤都已经跑通,那么你完成的不是一个简单 demo,而是一个完整的 MCP 入门闭环:

  1. 写了一个 TypeScript MCP Server
  2. 注册了一个真实可用的工具 fetch_market_data
  3. 接入了第三方 API,而不是只返回静态内容
  4. 用 Inspector 验证了工具层是否正常
  5. 用 Codex 验证了宿主能否调用工具

这一步很关键,因为它意味着你已经从“知道 MCP 概念”进入了“能做出一个可验证工具”的阶段。

本文本质上只做了一件事:

用一个真实的 CoinGecko 市场数据工具,把 MCP 的完整入门流程跑通。

刚接触 MCP 时,不需要一开始就把所有协议细节都啃透。更重要的是先建立几个直觉:

  • MCP 是一层工具协议
  • 业务逻辑仍然是你自己写
  • 宿主通过 MCP 发现并调用工具
  • Inspector 适合做调试验证
  • Codex 可以作为真实宿主消费你的工具能力

只要这条链路亲手跑通过一次,后面再扩展第二个工具、替换数据源、接入更多宿主,理解都会快很多。

下一步可以继续做三件事:

  1. 增加更多 symbol,并把映射改成可配置
  2. 给 CoinGecko 请求增加缓存和限流处理
  3. 再注册一个工具,例如 compare_market_data,比较多个币种的价格和涨跌幅

这时你就不只是“会跑一个 MCP 示例”,而是开始真正把 MCP 当成可扩展的工具协议来使用。