从 0 跑通 MCP:用 TypeScript 写一个 CoinGecko 工具,并接入 Inspector / Codex
如果你已经看过不少 MCP 介绍,但还没有亲手跑通过一个完整例子,这篇文章就是为你准备的。
本文不先展开复杂概念,也不一开始讨论远程部署,而是只做一件事:
从零写一个最小的 TypeScript MCP Server,接入 CoinGecko 的真实市场数据接口,并在 MCP Inspector 和 Codex 中完成验证。
这个示例最终只暴露一个工具:fetch_market_data。
它的功能很简单:输入 BTC、ETH 或 SOL,返回来自 CoinGecko 的实时市场价格、24 小时涨跌幅和更新时间。
但这个小工具会把 MCP 入门最关键的链路完整跑通:
- 写一个本地 MCP Server
- 注册一个可被宿主发现的工具
- 接入真实第三方 API
- 用 Inspector 做工具级调试
- 用 Codex 验证宿主是否真的调用了工具
配套示例代码已放在 GitHub,建议边看边对照实践:
- GitHub 仓库:mcp-tooling-example
一、这篇文章解决什么问题
学 MCP 时,新手最容易卡住的通常不是“概念完全看不懂”,而是:
不知道一个最小可运行项目到底应该长什么样。
所以本文的目标很明确。读完并跑通后,你应该能完成下面几件事:
- 理解 MCP 在这个例子里的定位
- 用 TypeScript 写一个最小 MCP Server
- 注册一个可调用工具:
fetch_market_data - 让工具真实调用 CoinGecko API
- 用 MCP Inspector 验证工具是否可用
- 把它接入 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 KeyCOINGECKO_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 件事:
- 读取环境变量,判断使用 Demo 还是 Pro 配置
- 把用户输入的 symbol 映射成 CoinGecko 识别的 coin id
- 调用 CoinGecko 的
/simple/price接口 - 提取价格、24 小时涨跌幅和更新时间
- 整理成统一的
MarketData结构返回
也就是说,这一层解决的是:
如何把用户能理解的输入,转换成第三方 API 能理解的请求,再把返回值整理成自己可控的输出。
为什么先手写 symbol 映射
这里没有一开始就支持所有币种,而是先手写了几个映射:
BTC -> bitcoinETH -> ethereumSOL -> 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 步理解:
- 创建一个 MCP Server,声明服务名称和版本。
- 注册
fetch_market_data工具。 - 用 Zod 描述输入参数,让宿主知道工具需要什么。
- 在工具处理函数中调用
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 调用链路已经跑通

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-demoid: bitcoinchange24hlastUpdatedAt
如果这些字段都出现了,通常就可以判断:
Codex 不是在凭语言模型常识回答,而是真的通过 MCP 调用了你的工具。
Codex 调用截图
下面这张图展示的是把 MCP Server 接入 Codex 后的调用结果。可以看到 Codex 明确调用了:
market_data.fetch_market_data({"symbol":"BTC"})
并返回了你在工具中定义的字段:
id: bitcoinsymbol: BTCpricesource: coingecko-demochange24hlastUpdatedAt
这说明 Codex 已经通过 MCP 工具拿到了真实返回结果。

五、跑通之后,你真正掌握了什么
如果前面的步骤都已经跑通,那么你完成的不是一个简单 demo,而是一个完整的 MCP 入门闭环:
- 写了一个 TypeScript MCP Server
- 注册了一个真实可用的工具
fetch_market_data - 接入了第三方 API,而不是只返回静态内容
- 用 Inspector 验证了工具层是否正常
- 用 Codex 验证了宿主能否调用工具
这一步很关键,因为它意味着你已经从“知道 MCP 概念”进入了“能做出一个可验证工具”的阶段。
本文本质上只做了一件事:
用一个真实的 CoinGecko 市场数据工具,把 MCP 的完整入门流程跑通。
刚接触 MCP 时,不需要一开始就把所有协议细节都啃透。更重要的是先建立几个直觉:
- MCP 是一层工具协议
- 业务逻辑仍然是你自己写
- 宿主通过 MCP 发现并调用工具
- Inspector 适合做调试验证
- Codex 可以作为真实宿主消费你的工具能力
只要这条链路亲手跑通过一次,后面再扩展第二个工具、替换数据源、接入更多宿主,理解都会快很多。
下一步可以继续做三件事:
- 增加更多 symbol,并把映射改成可配置
- 给 CoinGecko 请求增加缓存和限流处理
- 再注册一个工具,例如
compare_market_data,比较多个币种的价格和涨跌幅
这时你就不只是“会跑一个 MCP 示例”,而是开始真正把 MCP 当成可扩展的工具协议来使用。