跳到主要内容

SDK 与 API 契约(最底层:怎么跟后端对话)

30 秒导读: Context7 的所有上层功能(MCP 服务器、CLI、AI-SDK 工具)最终都靠 packages/sdk 跟后端说话。而这个 SDK 干的事小得惊人——它把「和 context7.com 后端对话」压缩成两个 HTTP 端点: 先 搜库 拿到库的 ID,再 取文档 拿到该库的内容。本章讲透这份最底层的数据契约,以及 SDK 用 Command 模式 + 带重试的 HttpClient 把它包成好用方法的全过程。

这是全组文档的第一章,也是最浅、最底层的一层。上面几层都站在它肩膀上: 02-mcp-server.md 把这两个方法暴露成 MCP 工具, 03-cli.md 把它们包成命令行, 04-ai-sdk-tools-agent.md 把它们变成给大模型调用的 tool。 全景与阅读顺序见 index.md


1. 这一层在解决什么(零基础也能懂)

假设你在做一个 AI 编程助手,想让它「查到某个库的最新官方文档,喂给模型」。你需要跟 Context7 的服务器要数据。但你不想每个上层(MCP、CLI、AI-SDK)都各写一遍「怎么拼 URL、怎么带 API key、网络抖动怎么重试、返回的 JSON 怎么解析」。

packages/sdk 就是把这些公共的脏活收进一个包。上层只需要:

// 示意,非源码:上层想要的心智模型
const c7 = new Context7({ apiKey });
const libs = await c7.searchLibrary("如何做路由", "react"); // 第一步:搜库
const docs = await c7.getContext("如何做路由", libs[0].id); // 第二步:取文档

一句话直觉: 把它想成一个「文档点唱机」——你先报个歌名(库名)查到唱片编号(库 ID), 再拿编号点歌(取该库的文档片段)。SDK 就是那台机器的投币口与出片口,后端才是唱片库。

本节到此不碰代码细节。记住一件事就够:这一层 = 两步、两个端点、一套统一的请求/重试/格式化。


2. 顶层全景(SDK 大概怎么转)

SDK 内部分三层,从用户手里的方法调用,一路下到裸 fetch:

你的代码
│ c7.searchLibrary(...) / c7.getContext(...)

┌─────────────────────────────────────────────┐
│ Context7 类 (client.ts) │ ← 门面:校验 API key、建 HttpClient
│ · searchLibrary / getContext 各带重载 │
└───────────────┬─────────────────────────────┘
│ new XxxCommand(...).exec(httpClient)

┌─────────────────────────────────────────────┐
│ Command 模式 (commands/*) │ ← 每个端点封成一个「命令对象」
│ · SearchLibraryCommand → v2/libs/search │ 命令知道:方法、路径、query、
│ · GetContextCommand → v2/context │ 以及「响应怎么格式化」
└───────────────┬─────────────────────────────┘
│ client.request({...})

┌─────────────────────────────────────────────┐
│ HttpClient (http/index.ts) │ ← 传输层:拼 URL、带 header、
│ · 指数退避重试、no-store、解析 json/txt │ 重试、判 ok、分流 json vs 文本
└───────────────┬─────────────────────────────┘

fetch → https://context7.com/api/...

各部件一句话职责:

部件干什么文件
Context7对外门面,校验 key、装配 HttpClient、暴露两个方法packages/sdk/src/client.ts
Command 基类定义「一条请求 = 方法 + 端点 + query/body + 如何取结果」packages/sdk/src/commands/command.ts
SearchLibraryCommand封装「搜库」端点,把响应映射成 Library[]packages/sdk/src/commands/search-library/index.ts
GetContextCommand封装「取文档」端点,合并代码/信息片段成 Documentation[]packages/sdk/src/commands/get-context/index.ts
HttpClient传输实现:URL 拼接、重试退避、cache、json/txt 分流packages/sdk/src/http/index.ts
format.ts把后端裸字段映射成对外整洁类型,并可渲染成文本packages/sdk/src/utils/format.ts
Context7ErrorSDK 统一的错误类型packages/sdk/src/error/index.ts

3. 核心契约:两步 = 两个端点

整个 Context7 后端,SDK 只用到两个 URL。它们被硬编码成一个常量数组,是本层最重要的事实。

packages/sdk/src/commands/command.ts:3_ENDPOINTS:

export const _ENDPOINTS = ["v2/libs/search", "v2/context"];

这两个端点对应「点唱机」的两步,缺一不可:

步骤端点SDK 方法输入输出
① 搜库v2/libs/searchsearchLibrary(query, libraryName)用户问题 + 库名Library[](带 id、trust/benchmark 分)
② 取文档v2/contextgetContext(query, libraryId)用户问题 + 库 IDDocumentation[](代码片段 + 信息片段)

为什么必须两步? 因为库名(如 "react")是模糊的、可能撞名;后端需要先用 v2/libs/search 做相关性排名,返回规范的库 ID(如 /react/react),第二步 v2/context 才用这个精确 ID 取文档。 第一步的 query 参数不是用来过滤库,而是喂给后端做相关性排序用(见 client.ts:76 的 JSDoc)。

两个端点在 SDK 里都走 GET(见 §5),尽管 Command 基类的默认方法是 POST——这个反差稍后解释。


4. 门面:Context7 类怎么开门

Context7 类(packages/sdk/src/client.ts:18)是上层唯一需要 new 的东西。它的构造函数做三件事, 顺序有讲究。

4.1 API key 的两道关卡

第一道是硬性的——没 key 直接抛错。client.ts:22-28:

const apiKey = config.apiKey || process.env.CONTEXT7_API_KEY;
if (!apiKey) {
throw new Context7Error(
"API key is required. Pass it in the config or set CONTEXT7_API_KEY environment variable."
);
}

key 的来源有优先级:显式传入的 config.apiKey 优先,否则回退到环境变量 CONTEXT7_API_KEY

第二道是软性的——前缀不对只警告,不拦。client.ts:13 定义前缀 ctx7sk,client.ts:30-32:

if (!apiKey.startsWith(API_KEY_PREFIX)) { // API_KEY_PREFIX = "ctx7sk"
console.warn(`API key should start with '${API_KEY_PREFIX}'`);
}

关键细节: 前缀校验只 console.warn不 throw。也就是说前缀不对的 key 仍会被拿去发请求 (真正的鉴权交给后端)。SDK 这里只做一个善意提醒。

4.2 装配 HttpClient(默认值都在这)

构造函数最后 new HttpClient(...)(client.ts:34-44),把一堆默认策略钉死在这里:

配置出处
baseUrlhttps://context7.com/apiclient.ts:12 DEFAULT_BASE_URL
鉴权头Authorization: Bearer <apiKey>client.ts:36-37
重试次数retries: 5client.ts:40
退避函数backoff = (n) => Math.exp(n) * 50(毫秒)client.ts:41
缓存cache: "no-store"client.ts:43

注意 baseUrl写死的(没有从 config 读),所以标准 SDK 永远打 context7.com/apicache: "no-store" 保证每次都拿最新文档、不吃浏览器/运行时缓存——对「取最新文档」这个诉求很关键。

4.3 方法重载:同名方法,按 type 返回不同类型

searchLibrarygetContext 各写了三个 TypeScript 重载签名 + 一个实现。以 searchLibrary 为例(client.ts:50-88),重载让调用方按 options.type 拿到精确的返回类型:

调用返回类型
searchLibrary(q, name, { type: "json" })Promise<Library[]>
searchLibrary(q, name, { type: "txt" })Promise<string>
searchLibrary(q, name)(默认)Promise<Library[]>

实现体本身很薄(client.ts:81-88)——只负责建命令、执行命令:

async searchLibrary(query, libraryName, options?) {
const command = new SearchLibraryCommand(query, libraryName, options);
return await command.exec(this.httpClient);
}

真正的逻辑都在 Command 里。这就引出下一节。


5. Command 模式:一个端点 = 一个命令对象

5.1 它要解决的小问题

每个端点都有一堆固定属性:用哪个 HTTP 方法、打哪个路径、带哪些 query、响应怎么转成对外类型。 把这些捆在一个对象里,门面就不用关心细节,只管 new XxxCommand(...).exec(client)。这就是 经典的命令模式(Command Pattern,把「一次请求」封装成可执行对象)

5.2 基类:请求的骨架

Command<TResult>(packages/sdk/src/commands/command.ts:13)存两样东西:request(方法+query+body) 和 endpoint(打哪个端点),并提供一个默认 exec(command.ts:25-38):

public async exec(client: Requester): Promise<TResult> {
const { result } = await client.request<TResult>({
method: this.request.method || "POST", // 基类默认 POST
path: [this.endpoint],
query: this.request.query,
body: this.request.body,
});
if (result === undefined) throw new TypeError("Request did not return a result");
return result;
}

一个容易踩的反差: 基类 exec 默认方法是 POST,但两个真实命令都用 GET 并且 各自 override 了 exec(见下)。所以基类的这段 exec 实际上在当前两个命令里都没被跑到—— 它是模式的「默认实现/兜底」,而子类因为要做响应格式化,选择完全重写。

5.3 子类一:SearchLibraryCommand(搜库)

packages/sdk/src/commands/search-library/index.ts:10。构造时先前置校验参数,再定端点:

if (!query || !libraryName) {
throw new Context7Error("query and libraryName are required"); // index.ts:14-15
}
// ...把 query、libraryName 塞进 queryParams
super({ method: "GET", query: queryParams }, "v2/libs/search"); // index.ts:23

它 override 的 exec(index.ts:28-46)做「请求 → 映射 → 按需转文本」:

const { result } = await client.request<ApiSearchResponse>({ method: "GET", ... });
const libraries = result.results.map(formatLibrary); // 后端裸字段 → 整洁 Library
if (this.responseType === "txt") return formatLibrariesAsText(libraries);
return libraries;

关键细节(搜库的 txt 是客户端渲染的): 搜库从不把 type 发给后端——它总是拿 JSON,再在 本地用 formatLibrariesAsText 拼成文本。这和取文档不同(下一节)。

5.4 子类二:GetContextCommand(取文档)

packages/sdk/src/commands/get-context/index.ts:10。它把 type 发给后端(index.ts:19-22):

const responseType = options?.type ?? DEFAULT_TYPE; // 默认 "json"
queryParams.type = responseType; // ← type 进了 query,发给后端
super({ method: "GET", query: queryParams }, "v2/context");

override 的 exec(index.ts:27-48)据此分流:

if (this.responseType === "txt" && typeof result === "string") {
return result; // 后端已渲染好文本,直接返回
}
const apiResult = result as ApiContextJsonResponse;
const codeDocs = apiResult.codeSnippets.map(formatCodeSnippet); // 代码片段
const infoDocs = apiResult.infoSnippets.map(formatInfoSnippet); // 信息片段
return [...codeDocs, ...infoDocs]; // 合并成一个 Documentation[]

关键契约: v2/context 的 JSON 响应里,文档被拆成两类片段——codeSnippets(代码)和 infoSnippets(说明文字)。SDK 把两者各自格式化后首尾拼接成一个统一的 Documentation[]

5.5 两个命令的契约对比

维度SearchLibraryCommandGetContextCommand
端点v2/libs/searchv2/context
参数校验querylibraryName 必填,缺则抛错不做必填校验
type 发给后端?(txt 在客户端拼)(txt 由后端渲染)
JSON 结构{ results: [...] }{ codeSnippets, infoSnippets }
映射函数formatLibraryformatCodeSnippet + formatInfoSnippet
对外类型Library[]Documentation[]

6. 格式化:后端裸字段 → 对外整洁类型

后端返回的字段名很「后端」(codeTitletrustScoreversions…)。packages/sdk/src/utils/format.ts 负责把它们翻译成文档里承诺的干净类型,同时补齐缺省值。

formatLibrary(format.ts:28-46)把搜库结果映射成 Library,并给可选字段兜底:

return {
id: r.id,
name: r.title, // title → name(改名)
description: r.description,
totalSnippets: r.totalSnippets ?? 0, // 缺省补 0
trustScore: r.trustScore ?? 0,
benchmarkScore: r.benchmarkScore ?? 0,
versions: r.versions,
};

formatCodeSnippet(format.ts:4-18)是精华之一:它把后端的 codeList(多个语言/代码块) 重新拼成 markdown 代码围栏,并在前面接上描述文字:

`codeList` 每项 → ```<language>\n<code>\n``` ,多块以空行相连;
若有 codeDescription,则「描述 + 空行 + 代码块」拼成最终 content。
(源码 format.ts:5-12)

文本渲染这块还有个可读性巧思:getTrustScoreLabel(format.ts:51-56)把数字信任分翻译成人话标签, 供 formatLibraryAsText 用:

trustScore标签
undefined< 0Unknown
>= 7High
>= 4(且 < 7)Medium
其余(< 4)Low

formatLibrariesAsText(format.ts:88-94)则处理空结果——没搜到时返回一句 "No documentation libraries found matching your query.",而不是空字符串。


7. 传输层:HttpClient 的重试、退避与响应分流

HttpClient(packages/sdk/src/http/index.ts:86)是真正碰 fetch 的地方,也是本层最有工程含量的一段。

7.1 拼 URL:GET 的 query 在这里变成 querystring

request(http/index.ts:124)先把 baseUrlpath/ 连起来;只有 GET 才把 query 对象编码进 URL(http/index.ts:128-139),undefined 的键会被跳过:

let url = [this.baseUrl, ...(req.path ?? [])].join("/");
if (method === "GET" && req.query) {
const queryParams = new URLSearchParams();
Object.entries(req.query).forEach(([key, value]) => {
if (value !== undefined) queryParams.append(key, String(value));
});
// ...url += `?${queryString}`
}

请求选项里钉了 keepalive: true 和构造时传入的 cache(即 no-store)(http/index.ts:141-148)。

7.2 指数退避重试(只重试网络错误)

重试循环在 http/index.ts:153-166。默认 attempts = 5(http/index.ts:119),循环条件是 i <= attempts,所以最多发 6 次 fetch;每次失败后按 backoff(i) 毫秒等待再试:

for (let i = 0; i <= this.retry.attempts; i++) {
try {
res = await fetch(url, requestOptions);
break; // 成功就跳出
} catch (error_) {
if (requestOptions.signal?.aborted) throw error_; // 被 abort:立刻抛,不重试
error = error_ as Error;
if (i < this.retry.attempts) {
await new Promise((r) => setTimeout(r, this.retry.backoff(i))); // 退避等待
}
}
}

退避公式 backoff = (n) => Math.exp(n) * 50(http/index.ts:120client.ts:41)是指数增长, 不是常见的 2^n。实际等待(毫秒)大致是:

重试轮次 nMath.exp(n) * 50 约等于
050 ms
1136 ms
2369 ms
31004 ms
42730 ms

两个重要边界:

  • 只有 fetch 抛错(网络层失败)才重试。 HTTP 4xx/5xx 属于「请求成功但状态非 ok」,不进这个 catch,而是走 §7.3 直接抛 Context7Error——不重试
  • 被 abort 的请求不重试,signal.aborted 时直接把原错误抛出去。

循环结束若仍无 res,抛累积的错误或 "Exhausted all retries"(http/index.ts:167-169)。

7.3 判 ok 与 json/txt 分流

拿到响应后先判 res.ok(http/index.ts:171-177):非 ok 时尝试读 JSON 错误体,按 error → message → statusText 的优先级取一条消息,包成 Context7Error 抛出。

然后按 content-type 分流(http/index.ts:179-188):

content-type 含 "application/json" ?
├─ 是 → res.json(),作为 result 返回
└─ 否 → res.text(),并从响应头抽取分页信息(TxtResponseHeaders)一并返回

文本响应会额外解析 x-context7-* 头(page/limit/totalPages/hasNext/hasPrev/totalTokens), 逻辑在 extractTxtResponseHeaders(http/index.ts:191-211)——任一头缺失就整体返回 undefined (全有才给)。这套分页头主要服务取文档的 txt 模式。


8. 错误模型:统一到 Context7Error

SDK 对外只抛一种「业务错误」——Context7Error(packages/sdk/src/error/index.ts:1),它就是 一个把 name 设成 "Context7Error"Error 子类。上层可以用 instanceof Context7Error 稳稳分辨 「这是 SDK/后端语义错误」还是「别的意外」。

它出现在这些地方(都可 grep):

触发点文件:行含义
缺 API keyclient.ts:25构造 Context7 时没 key
搜库缺参commands/search-library/index.ts:14query/libraryName 为空
后端返回非 okhttp/index.ts:1764xx/5xx,消息取自响应体
结果为空两个命令的 exec请求成功但无 result,抛 Context7Error(基类兜底处抛的是 TypeError)

注意基类 Command.exec 对空结果抛的是 TypeError(command.ts:34),而子类 override 后抛的是 Context7Error(如 search-library/index.ts:36)——因为子类走的是自己那份 exec。


9. 端到端:两步调用长什么样

把前面所有部件串起来,一次完整的「搜库 → 取文档」是这样(这也是上层 MCP/CLI 内部真正在做的事):

// 示意,非源码:演示两步契约
import { Context7 } from "@upstash/context7-sdk";

const c7 = new Context7({ apiKey: process.env.CONTEXT7_API_KEY }); // 校验 key、装配 HttpClient

// 第一步:搜库 → GET v2/libs/search,拿到候选库(默认 json)
const libs = await c7.searchLibrary("如何配置路由", "next.js");
const best = libs[0]; // best.id 形如 "/vercel/next.js"

// 第二步:取文档 → GET v2/context,用上一步的库 ID 精确取内容
const docs = await c7.getContext("如何配置路由", best.id);
// docs 是 Documentation[]:先是代码片段,后接信息片段

// 想要给人看的纯文本?把 type 切成 "txt"
const text = await c7.getContext("如何配置路由", best.id, { type: "txt" });

重点看: 第一步的返回决定第二步的输入——libs[0].id 是唯一把两个端点「串起来」的桥。这就是 本章开头那句「两步 = 两个端点」的完整含义。


10. 边界与局限(这一层刻意不做什么)

  • baseUrl 写死。 Context7Config 只认 apiKey(commands/types.ts:1-3),标准客户端无法改 base URL——想打自建/代理后端,得直接 new HttpClient(...),门面类不给口子。
  • 不重试 HTTP 错误。 只有网络层 fetch 抛错才退避重试;后端 429/5xx 会立即变成 Context7Error,由上层自己决定是否重试(见 §7.2)。
  • 前缀不拦。 ctx7sk 前缀不对只 console.warn,错的 key 照发,失败要等后端返回鉴权错误。
  • 契约锁死在两个端点。 SDK 不暴露别的后端能力;_ENDPOINTS 就这两条,想扩展得改 SDK 本体。
  • MCP/CLI 专属逻辑不在这层。 传输、认证协商、OAuth、工具封装分别在 02/03 章;本层只管 「裸 HTTP 契约 + 格式化」。

11. 代码地图(导航索引)

按符号名 grep 最抗行号漂移。

主题文件路径符号
门面类 / key 校验 / 装配packages/sdk/src/client.tsContext7DEFAULT_BASE_URLAPI_KEY_PREFIX
两个端点常量packages/sdk/src/commands/command.ts_ENDPOINTSEndpointVariants
命令基类packages/sdk/src/commands/command.tsCommandCommand.exec
搜库命令packages/sdk/src/commands/search-library/index.tsSearchLibraryCommand
取文档命令packages/sdk/src/commands/get-context/index.tsGetContextCommand
传输 / 重试 / 分流packages/sdk/src/http/index.tsHttpClientrequestextractTxtResponseHeaders
重试/请求类型packages/sdk/src/http/index.tsRequesterRetryConfigContext7Response
格式化packages/sdk/src/utils/format.tsformatLibraryformatLibrariesAsTextformatCodeSnippetformatInfoSnippetgetTrustScoreLabel
对外类型packages/sdk/src/commands/types.tsContext7ConfigLibraryDocumentationSearchLibraryOptionsGetContextOptions
搜库/取文档响应型packages/sdk/src/commands/search-library/types.ts.../get-context/types.tsApiSearchResponseApiContextJsonResponseApiCodeSnippetApiInfoSnippet
错误类型packages/sdk/src/error/index.tsContext7Error

下一章: 02-mcp-server.md —— 看这两个方法如何被包成 MCP 的两个工具, 接上传输层与安全边界。