从 0 开始实操 MCP:用 TypeScript 接入 CoinGecko,并在 Inspector / Codex 中验证

从 0 开始实操 MCP:用 TypeScript 接入 CoinGecko,并在 Inspector / Codex 中验证

如果你已经看过不少 MCP 介绍,却始终没有真正跑通过一个完整例子,那么这篇文章会更适合你。

这篇文章不讲太多抽象概念,也不一开始堆很多工具,而是只做一件事:

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

整个过程只围绕一个工具展开:fetch_market_data

你可以把它理解成一条完整的入门链路:

  • 先理解 MCP 在这个案例里到底是什么
  • 再动手写一个最小可运行的 MCP Server
  • 然后接入真实 API
  • 最后验证这个工具确实能被宿主发现和调用

这样学的好处是,你不会停留在“知道 MCP 是什么”,而是能真正完成一次从开发到验证的闭环。

本文配套示例代码已上传 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 和 Agent、业务逻辑、应用框架混在一起。其实更准确的理解应该是:

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

为什么这篇文章先从 STDIO 开始,而不是 HTTP

MCP 支持不同传输方式,但对新手来说,最适合入门的通常不是远程 HTTP,而是本地 STDIO

原因很简单,STDIO 足够直接:

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

也就是说,你现在最需要的是先把一个工具从“写出来”到“被调用成功”跑通,而不是一上来解决部署问题。

所以这篇文章的策略是:

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

这个顺序对初学者更稳,也更容易建立对 MCP 的直觉。


三、开始动手:从 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,处理市场数据

这样拆的好处很明显:

  1. 协议层和业务层分开
    你能更清楚地理解:什么是 MCP,什么是业务逻辑

  2. 后续更容易扩展
    将来无论接 CLI、HTTP、ChatGPT,甚至测试脚本,都可以复用 domain

  3. 更符合真实项目演进方式
    哪怕这是一个入门项目,也最好从一开始建立基本的分层意识

对新手来说,这种拆分不是“为了架构而架构”,而是为了以后少踩坑。

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. 把结果整理成统一的数据结构返回

换句话说,这一层解决的是:

“怎么把一个用户能理解的输入,变成第三方 API 能理解的请求,再把返回值整理成自己可控的输出格式。”

这就是典型的业务逻辑层职责。

为什么这里先手写 symbol 映射,而不是自动支持所有币种

你会发现这里没有一开始就去拉 /coins/list,而是先手写了几个映射:

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

这样做不是因为它更高级,而是因为它更适合入门阶段。

原因有两个:

  1. 先降低复杂度,优先跑通链路
    新手最重要的不是“支持所有币种”,而是先确认整条链路能正常工作。如果一开始就引入动态映射、缓存、搜索、容错等问题,排错成本会明显升高。

  2. 把问题拆小,更容易理解
    在这一阶段,你只需要先理解工具如何注册、请求如何发出、数据如何返回、宿主如何调用。至于“如何支持所有 symbol”,那是下一阶段的问题。

技术学习里,一个很重要的原则就是:

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

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
    这一步是在声明你的服务身份。宿主连接时,会把它当成一个 MCP Server 来识别。

  2. 注册一个工具
    这里注册了一个名为 fetch_market_data 的工具。从宿主视角看,它不关心你内部怎么实现,它只关心工具叫什么、接收什么参数、返回什么结果。

  3. 用 Zod 描述输入参数
    这一步不是为了“写得更正式”,而是为了让工具输入更可验证、更清晰。这样在 Inspector 或其他宿主里,也更容易看到工具需要什么参数。

  4. 把业务逻辑接进来
    MCP 层只负责协议和工具暴露,真正的业务仍然交给 domain/market.ts

这就是为什么前面要先拆文件。如果你把所有代码都写在一个地方,新手很容易分不清“协议代码”和“业务代码”分别在干什么。

7. 启动服务并理解运行细节

接下来给 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,而不是 stdout

如果你使用的是 STDIO 传输,那么:

stdout 是 MCP 协议消息通道,不能随便输出普通日志。

也就是说,下面这种做法是有风险的:

console.log("server started");

因为这类普通日志会混进协议输出,可能直接破坏通信。

所以在 STDIO 模式下,普通日志应该写到:

console.error(...)

也就是 stderr。

这类细节很适合在入门文章里强调一下。因为很多人第一次跑不通,不是 MCP 本身有问题,而是被日志输出干扰了。


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

1. 用 MCP Inspector 验证

在真正接入 Codex 前,最好的做法是先用 MCP Inspector 验证。因为 Inspector 更适合做“工具级调试”。

启动 Inspector

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

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

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

在 Inspector 里重点验证什么

你不需要一上来测试很多场景,先抓住 3 个核心问题:

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

也就是说,Inspector 阶段的重点不是“功能多强”,而是“工具链路是否成立”。

测试正常输入

输入:

{
  "symbol": "BTC"
}

预期返回类似:

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

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

测试异常输入

除了成功场景,建议至少测下面几种失败输入。

空字符串
{
  "symbol": ""
}
不支持的 symbol
{
  "symbol": "DOGE"
}
缺少字段
{}

这一步的目的不是追求“所有输入都成功”,而是确认下面三件事:

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

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

执行结果证明图

下面这张图是 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

这些字段如果都出现了,通常就可以比较确定:

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

执行结果证明图

下面这张图展示的是把 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,而是一个完整的入门闭环:

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

这一步很重要,因为它意味着你已经跨过了 MCP 入门最难的那道坎:

从“知道概念”进入“能做出一个可验证的工具”。

很多人学到这里,后面再增加第二个工具、替换数据源、接入更多宿主,都会顺很多。

这篇文章本质上只做了一件事:

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

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

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

只要这条链路你亲手跑通过一次,后面再去扩展更多工具、更多数据源,甚至更完整的 agent 体系,理解都会快很多。