@faapi/schema 0.0.0-canary.4ab8d97 → 0.0.0-canary.4e89b9b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @faapi/schema
2
+
3
+ > 将 faapi 路由 schema 通过 MCP 协议暴露给 AI 助手
4
+
5
+ `@faapi/schema` 是 faapi 的扩展包,基于 [`@faapi/mcp`](../mcp/)(纯手写 MCP Server SDK)构建 MCP Server,通过 Streamable HTTP transport 在 `/mcp` 端点暴露路由 schema,让 AI 助手(如 Claude、Codex)能查询你的 API 路由结构、参数类型,无需阅读源代码即可理解接口定义。
6
+
7
+ 不依赖 `@modelcontextprotocol/sdk`,MCP 协议层完全由 `@faapi/mcp` 实现。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ pnpm add @faapi/schema
13
+ # 或
14
+ npm install @faapi/schema
15
+ ```
16
+
17
+ 要求 Node.js >= 24。
18
+
19
+ ## 快速开始
20
+
21
+ 在 `faapi.config.ts` 中声明插件:
22
+
23
+ ```ts
24
+ export default {
25
+ plugins: ['@faapi/schema'],
26
+ } satisfies FaapiConfig;
27
+ ```
28
+
29
+ 启动 dev server 后,MCP 端点自动挂载到 `/mcp` 路径。插件声明即为开关——不需要时从 `plugins` 数组移除即可,也可用 `{ package: '@faapi/schema', enable: false }` 临时禁用。
30
+
31
+ ## MCP 工具
32
+
33
+ | 工具名 | 功能 |
34
+ |--------|------|
35
+ | `list_routes` | 列出所有 HTTP 路由(方法、路径、是否动态路由) |
36
+ | `get_route_schema` | 获取单个路由的详细输入参数(名称、类型、是否必填) |
37
+ | `get_api_schema` | 获取所有路由的完整 schema(类似 OpenAPI) |
38
+
39
+ ## MCP 客户端配置
40
+
41
+ AI 助手通过 Streamable HTTP 连接到 `http://localhost:3000/mcp`(端口取决于 `PORT` 环境变量)。以支持 HTTP transport 的 MCP 客户端为例:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "faapi": {
47
+ "url": "http://localhost:3000/mcp"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ 连接流程:`POST /mcp` 发送 `initialize` 请求 → 从响应 `Mcp-Session-Id` header 获取会话 ID → 后续 `tools/list`、`tools/call` 请求携带该 header → `DELETE /mcp`(带 session header)销毁会话。
54
+
55
+ ## 许可证
56
+
57
+ [MIT](https://github.com/faapi/faapi/blob/main/LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { PluginContext } from '@faapi/faapi';
2
2
 
3
+ /**
4
+ * faapi Schema Server — 通过 MCP 协议以 resource 形式暴露路由 schema 供 AI 助手查询
5
+ *
6
+ * 基于 @faapi/mcp(纯手写 MCP Server SDK),不依赖 @modelcontextprotocol/sdk。
7
+ * 通过 faapi 插件机制挂载到 /mcp 端点,AI 助手通过 Streamable HTTP 连接。
8
+ *
9
+ * 提供两种 MCP 能力:
10
+ * - resources: 每个路由注册为静态 resource + by-method resourceTemplate
11
+ * - completion: 为 resource template 的 method 参数提供补全
12
+ *
13
+ * 不注册 tool——查 schema 是读数据(resource 语义),不是执行动作(tool 语义)。
14
+ * resource 还有 AI 客户端原生 UI、可缓存、支持 subscription 等 tool 做不到的优势。
15
+ */
16
+
3
17
  /**
4
18
  * faapi 插件入口
5
19
  *
@@ -9,6 +23,8 @@ import { PluginContext } from '@faapi/faapi';
9
23
  * plugins: ['@faapi/schema'],
10
24
  * } satisfies FaapiConfig;
11
25
  * ```
26
+ *
27
+ * 插件在 /mcp 路径挂载 MCP 端点,AI 助手通过 Streamable HTTP 连接。
12
28
  */
13
29
  declare const _default: {
14
30
  name: string;
package/dist/index.js CHANGED
@@ -1,36 +1,52 @@
1
1
  // src/schemaServer.ts
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
2
+ import { readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { createMcpServer, createMcpNodeHandler } from "@faapi/mcp";
5
5
 
6
6
  // src/routeSchema.ts
7
- import path from "path";
7
+ import ts from "typescript";
8
8
  import {
9
- getSchemaProperties,
10
- getInputTypeForMethod
9
+ collectRouteSchemaSources,
10
+ createProgram,
11
+ invalidateProgramCache,
12
+ extractTypeInfo,
13
+ resolveTypeNode,
14
+ getInputTypeForMethod,
15
+ SchemaExtractionError
11
16
  } from "@faapi/faapi";
12
17
  function buildRouteSchemas(routes, rootDir) {
18
+ const { sources } = collectRouteSchemaSources(routes, rootDir);
19
+ const sourceMap = /* @__PURE__ */ new Map();
20
+ for (const source of sources) {
21
+ sourceMap.set(`${source.urlPath}#${source.schemaName}`, source);
22
+ }
23
+ const filePathMap = /* @__PURE__ */ new Map();
24
+ for (const source of sources) {
25
+ filePathMap.set(source.urlPath, source.filePath);
26
+ }
13
27
  return routes.map((route) => {
14
- const absoluteFilePath = path.resolve(rootDir, route.filePath);
15
- const inputs = extractInputSchemas(absoluteFilePath, route.method, route);
28
+ const inputs = extractInputSchemas(route, sourceMap);
29
+ const output = extractOutputSchema(route, filePathMap.get(route.urlPath));
16
30
  return {
17
31
  method: route.method,
18
32
  path: route.urlPath,
19
33
  filePath: route.filePath,
20
34
  isDynamic: route.isDynamic,
21
- inputs
35
+ inputs,
36
+ output
22
37
  };
23
38
  });
24
39
  }
25
- function extractInputSchemas(filePath, method, route) {
40
+ function extractInputSchemas(route, sourceMap) {
26
41
  const inputs = [];
27
- const inputType = getInputTypeForMethod(method);
28
- const schema = getSchemaProperties(filePath, method, inputType);
29
- if (schema) {
42
+ const inputType = getInputTypeForMethod(route.method);
43
+ const schemaName = `${route.method.toUpperCase()}${capitalize(inputType)}`;
44
+ const source = sourceMap.get(`${route.urlPath}#${schemaName}`);
45
+ if (source?.typeInfo) {
30
46
  inputs.push({
31
47
  source: inputType,
32
- schemaName: schema.schemaName,
33
- properties: schema.properties
48
+ schemaName: source.typeInfo.name,
49
+ properties: toParamSchemas(source.typeInfo.properties)
34
50
  });
35
51
  } else {
36
52
  inputs.push({
@@ -40,138 +56,275 @@ function extractInputSchemas(filePath, method, route) {
40
56
  });
41
57
  }
42
58
  if (route.isDynamic && route.paramNames.length > 0) {
43
- const paramsSchema = getSchemaProperties(filePath, method, "params");
44
- const paramsProps = paramsSchema && paramsSchema.schemaName ? paramsSchema.properties : route.paramNames.map((name) => ({
45
- name,
46
- type: "string",
47
- required: true
48
- }));
49
59
  inputs.push({
50
60
  source: "params",
51
- schemaName: paramsSchema?.schemaName ?? null,
52
- properties: paramsProps
61
+ schemaName: null,
62
+ properties: route.paramNames.map((name) => ({ name, type: "string", required: true }))
53
63
  });
54
64
  }
55
65
  return inputs;
56
66
  }
67
+ function extractOutputSchema(route, filePath) {
68
+ if (!filePath) return null;
69
+ try {
70
+ const program = createProgram(filePath);
71
+ const sourceFile = program.getSourceFile(filePath);
72
+ if (!sourceFile) return null;
73
+ const checker = program.getTypeChecker();
74
+ const handlerName = route.method.toUpperCase();
75
+ let returnTypeNode;
76
+ ts.forEachChild(sourceFile, (node) => {
77
+ if (returnTypeNode) return;
78
+ if (ts.isFunctionDeclaration(node) && node.name?.text === handlerName) {
79
+ returnTypeNode = node.type;
80
+ }
81
+ });
82
+ if (!returnTypeNode) return null;
83
+ const unwrapped = unwrapPromise(returnTypeNode);
84
+ if (unwrapped === null) return null;
85
+ const runtimeType = resolveTypeNode(unwrapped, checker);
86
+ return runtimeTypeToOutput(unwrapped, runtimeType, program, filePath, checker);
87
+ } catch (err) {
88
+ if (err instanceof SchemaExtractionError) return null;
89
+ throw err;
90
+ }
91
+ }
92
+ function unwrapPromise(typeNode) {
93
+ if (!ts.isTypeReferenceNode(typeNode)) return typeNode;
94
+ const typeName = typeNode.typeName.getText();
95
+ if (typeName !== "Promise") return typeNode;
96
+ const arg = typeNode.typeArguments?.[0];
97
+ if (!arg) return typeNode;
98
+ if (ts.isTypeNode(arg) && arg.kind === ts.SyntaxKind.VoidKeyword) return null;
99
+ return arg;
100
+ }
101
+ function runtimeTypeToOutput(typeNode, runtimeType, program, filePath, _checker) {
102
+ if (ts.isTypeLiteralNode(typeNode)) {
103
+ if (runtimeType.kind === "object") {
104
+ return {
105
+ schemaName: null,
106
+ properties: toParamSchemas(runtimeType.properties)
107
+ };
108
+ }
109
+ }
110
+ if (ts.isArrayTypeNode(typeNode) && runtimeType.kind === "array") {
111
+ const element = runtimeType.element;
112
+ if (element.kind === "ref") {
113
+ const typeInfo = extractTypeInfo(program, filePath, element.name);
114
+ if (typeInfo) {
115
+ return {
116
+ schemaName: element.name,
117
+ properties: toParamSchemas(typeInfo.properties)
118
+ };
119
+ }
120
+ }
121
+ if (element.kind === "object") {
122
+ const elementNode = typeNode.elementType;
123
+ if (elementNode && ts.isTypeReferenceNode(elementNode)) {
124
+ const elementName = elementNode.typeName.getText();
125
+ const typeInfo = extractTypeInfo(program, filePath, elementName);
126
+ if (typeInfo) {
127
+ return {
128
+ schemaName: elementName,
129
+ properties: toParamSchemas(typeInfo.properties)
130
+ };
131
+ }
132
+ }
133
+ return {
134
+ schemaName: null,
135
+ properties: toParamSchemas(element.properties)
136
+ };
137
+ }
138
+ return { schemaName: null, properties: [] };
139
+ }
140
+ if (ts.isTypeReferenceNode(typeNode)) {
141
+ const typeName = typeNode.typeName.getText();
142
+ if (runtimeType.kind === "array") {
143
+ const element = runtimeType.element;
144
+ if (element.kind === "ref") {
145
+ const typeInfo = extractTypeInfo(program, filePath, element.name);
146
+ if (typeInfo) {
147
+ return {
148
+ schemaName: element.name,
149
+ properties: toParamSchemas(typeInfo.properties)
150
+ };
151
+ }
152
+ }
153
+ if (element.kind === "object") {
154
+ return {
155
+ schemaName: null,
156
+ properties: toParamSchemas(element.properties)
157
+ };
158
+ }
159
+ return { schemaName: null, properties: [] };
160
+ }
161
+ if (runtimeType.kind === "ref" || runtimeType.kind === "object") {
162
+ const typeInfo = extractTypeInfo(program, filePath, typeName);
163
+ if (typeInfo) {
164
+ return {
165
+ schemaName: typeName,
166
+ properties: toParamSchemas(typeInfo.properties)
167
+ };
168
+ }
169
+ }
170
+ }
171
+ return { schemaName: null, properties: [] };
172
+ }
173
+ function capitalize(s) {
174
+ return s.charAt(0).toUpperCase() + s.slice(1);
175
+ }
176
+ function toParamSchemas(properties) {
177
+ return properties.map((prop) => ({
178
+ name: prop.name,
179
+ type: runtimeTypeToString(prop.type),
180
+ required: !prop.optional
181
+ }));
182
+ }
183
+ function runtimeTypeToString(type) {
184
+ switch (type.kind) {
185
+ case "string":
186
+ case "number":
187
+ case "boolean":
188
+ case "bigint":
189
+ case "null":
190
+ case "undefined":
191
+ case "date":
192
+ return type.kind;
193
+ case "literal":
194
+ return JSON.stringify(type.value);
195
+ case "array":
196
+ return `${runtimeTypeToString(type.element)}[]`;
197
+ case "tuple":
198
+ return `[${type.elements.map((e) => (e.rest ? "..." : "") + runtimeTypeToString(e.type) + (e.optional ? "?" : "")).join(", ")}]`;
199
+ case "object":
200
+ return "object";
201
+ case "union":
202
+ return type.members.map(runtimeTypeToString).join(" | ");
203
+ case "record":
204
+ return `Record<${runtimeTypeToString(type.key)}, ${runtimeTypeToString(type.value)}>`;
205
+ case "ref":
206
+ return type.name;
207
+ case "any":
208
+ case "unknown":
209
+ return "unknown";
210
+ default:
211
+ return "unknown";
212
+ }
213
+ }
57
214
 
58
215
  // src/schemaServer.ts
59
- function isSchemaEnabled() {
60
- const envValue = process.env.FAAPI_SCHEMA;
61
- if (envValue === "1" || envValue === "true") return true;
62
- if (envValue === "0" || envValue === "false") return false;
63
- return process.env.NODE_ENV !== "production";
216
+ var MCP_PATH = "/mcp";
217
+ var HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
218
+ function readPackageVersion() {
219
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
220
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
221
+ return pkg.version ?? "0.0.0";
64
222
  }
65
- function createSchemaServer(routes, rootDir) {
66
- const server = new McpServer({
223
+ function routeToUri(route) {
224
+ return `faapi://route/${route.method}${route.path}`;
225
+ }
226
+ function createSchemaServer(getRoutes, rootDir) {
227
+ const mcp = createMcpServer({
67
228
  name: "faapi-schema",
68
- version: "0.0.1"
229
+ version: readPackageVersion(),
230
+ // 路由变化时主动推送 notifications/resources/list_changed
231
+ resourcesListChanged: true
69
232
  });
233
+ let cachedRoutes = null;
70
234
  let cachedSchemas = null;
235
+ const registeredUris = /* @__PURE__ */ new Set();
236
+ let resourceRegistered = false;
71
237
  function getSchemas() {
72
- if (!cachedSchemas) {
73
- cachedSchemas = buildRouteSchemas(routes, rootDir);
238
+ const currentRoutes = getRoutes();
239
+ if (currentRoutes !== cachedRoutes || !cachedSchemas) {
240
+ cachedRoutes = currentRoutes;
241
+ cachedSchemas = buildRouteSchemas(currentRoutes, rootDir);
242
+ registerResources(cachedSchemas);
243
+ if (resourceRegistered) {
244
+ mcp.notifyResourcesListChanged();
245
+ }
246
+ resourceRegistered = true;
74
247
  }
75
248
  return cachedSchemas;
76
249
  }
77
- server.tool(
78
- "list_routes",
79
- "\u5217\u51FA\u5F53\u524D faapi \u5E94\u7528\u7684\u6240\u6709 API \u8DEF\u7531\uFF0C\u5305\u62EC\u65B9\u6CD5\u3001\u8DEF\u5F84\u3001\u662F\u5426\u52A8\u6001\u8DEF\u7531",
80
- {},
81
- () => {
82
- const schemas = getSchemas();
83
- const routesList = schemas.map((r) => ({
84
- method: r.method,
85
- path: r.path,
86
- isDynamic: r.isDynamic,
87
- filePath: r.filePath
88
- }));
89
- return {
90
- content: [
91
- {
92
- type: "text",
93
- text: JSON.stringify(routesList, null, 2)
94
- }
95
- ]
96
- };
250
+ function registerResources(schemas) {
251
+ for (const uri of registeredUris) {
252
+ mcp.removeResource(uri);
97
253
  }
98
- );
99
- server.tool(
100
- "get_route_schema",
101
- "\u83B7\u53D6\u6307\u5B9A\u8DEF\u7531\u7684\u8BE6\u7EC6\u63A5\u53E3\u4FE1\u606F\uFF0C\u5305\u62EC\u8F93\u5165\u53C2\u6570\u7684\u540D\u79F0\u3001\u7C7B\u578B\u3001\u662F\u5426\u5FC5\u586B",
102
- {
103
- method: z.string().describe("HTTP \u65B9\u6CD5\uFF0C\u5982 GET\u3001POST"),
104
- path: z.string().describe("\u8DEF\u7531\u8DEF\u5F84\uFF0C\u5982 /auth/login")
105
- },
106
- ({ method, path: path2 }) => {
107
- const schemas = getSchemas();
108
- const route = schemas.find((r) => r.method === method.toUpperCase() && r.path === path2);
109
- if (!route) {
254
+ registeredUris.clear();
255
+ for (const route of schemas) {
256
+ const uri = routeToUri(route);
257
+ const captured = route;
258
+ mcp.resource(uri, {
259
+ name: `${route.method} ${route.path}`,
260
+ mimeType: "application/json",
261
+ read: async () => ({
262
+ contents: [
263
+ {
264
+ uri,
265
+ mimeType: "application/json",
266
+ text: JSON.stringify(captured, null, 2)
267
+ }
268
+ ]
269
+ })
270
+ });
271
+ registeredUris.add(uri);
272
+ }
273
+ }
274
+ mcp.resourceTemplate("faapi://routes/by-method/{method}", {
275
+ name: "routes-by-method",
276
+ description: "\u6309 HTTP \u65B9\u6CD5\u8FC7\u6EE4\u8DEF\u7531,\u8FD4\u56DE\u8BE5\u65B9\u6CD5\u7684\u6240\u6709\u8DEF\u7531\u5217\u8868",
277
+ read: async (uri, params) => {
278
+ const method = (params.method ?? "").toUpperCase();
279
+ if (!HTTP_METHODS.includes(method)) {
110
280
  return {
111
- content: [
281
+ contents: [
112
282
  {
113
- type: "text",
114
- text: JSON.stringify({ error: `\u672A\u627E\u5230\u8DEF\u7531 ${method.toUpperCase()} ${path2}` })
283
+ uri,
284
+ mimeType: "application/json",
285
+ text: "[]"
115
286
  }
116
287
  ]
117
288
  };
118
289
  }
290
+ const schemas = getSchemas().filter((r) => r.method === method);
119
291
  return {
120
- content: [
292
+ contents: [
121
293
  {
122
- type: "text",
123
- text: JSON.stringify(route, null, 2)
294
+ uri,
295
+ mimeType: "application/json",
296
+ text: JSON.stringify(schemas, null, 2)
124
297
  }
125
298
  ]
126
299
  };
127
300
  }
128
- );
129
- server.tool(
130
- "get_api_schema",
131
- "\u83B7\u53D6\u5F53\u524D\u5E94\u7528\u6240\u6709\u63A5\u53E3\u7684\u5B8C\u6574 schema\uFF0C\u7C7B\u4F3C OpenAPI \u89C4\u8303\uFF0C\u5305\u542B\u6BCF\u4E2A\u8DEF\u7531\u7684\u8F93\u5165\u53C2\u6570\u5B9A\u4E49",
132
- {},
133
- () => {
134
- const schemas = getSchemas();
135
- const apiSchema = {};
136
- for (const route of schemas) {
137
- const key = `${route.method} ${route.path}`;
138
- apiSchema[key] = {
139
- method: route.method,
140
- path: route.path,
141
- isDynamic: route.isDynamic,
142
- inputs: route.inputs.map((input) => ({
143
- source: input.source,
144
- schemaName: input.schemaName,
145
- properties: input.properties
146
- }))
147
- };
148
- }
301
+ });
302
+ mcp.completion(
303
+ { type: "ref/resource", uri: "faapi://routes/by-method/{method}" },
304
+ "method",
305
+ (value) => {
306
+ const upper = value.toUpperCase();
149
307
  return {
150
- content: [
151
- {
152
- type: "text",
153
- text: JSON.stringify(apiSchema, null, 2)
154
- }
155
- ]
308
+ values: HTTP_METHODS.filter((m) => m.startsWith(upper))
156
309
  };
157
310
  }
158
311
  );
159
- return server;
160
- }
161
- async function startSchemaServer(routes, rootDir) {
162
- const server = createSchemaServer(routes, rootDir);
163
- const transport = new StdioServerTransport();
164
- await server.connect(transport);
312
+ getSchemas();
313
+ return mcp;
165
314
  }
166
315
  var schemaServer_default = {
167
316
  name: "@faapi/schema",
168
317
  setup(ctx) {
169
- if (!isSchemaEnabled()) {
170
- console.log("- Schema server disabled (FAAPI_SCHEMA=0 or production mode)");
171
- return;
172
- }
173
- console.log("- Schema server enabled (stdio)");
174
- void startSchemaServer(ctx.routes, ctx.rootDir);
318
+ const mcp = createSchemaServer(ctx.getRoutes, ctx.rootDir);
319
+ const nodeHandler = createMcpNodeHandler(mcp);
320
+ ctx.wrapHandler?.((original) => (req, res) => {
321
+ const url = new URL(req.url ?? "/", "http://localhost");
322
+ if (url.pathname === MCP_PATH) {
323
+ return nodeHandler(req, res);
324
+ }
325
+ return original(req, res);
326
+ });
327
+ console.log(`- Schema server enabled at ${MCP_PATH} (Streamable HTTP)`);
175
328
  }
176
329
  };
177
330
  export {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/schemaServer.ts","../src/routeSchema.ts"],"sourcesContent":["import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { z } from 'zod';\nimport type { RouteManifest, RouteInfo, FaapiPlugin, PluginContext } from '@faapi/faapi';\nimport { buildRouteSchemas } from './routeSchema';\n\n/**\n * 判断 schema server 是否应该启用\n * - FAAPI_SCHEMA=1 强制开启\n * - FAAPI_SCHEMA=0 强制关闭\n * - 未设置时:开发环境默认开启,生产环境默认关闭\n */\nexport function isSchemaEnabled(): boolean {\n const envValue = process.env.FAAPI_SCHEMA;\n if (envValue === '1' || envValue === 'true') return true;\n if (envValue === '0' || envValue === 'false') return false;\n // 未设置时根据 NODE_ENV 判断\n return process.env.NODE_ENV !== 'production';\n}\n\n/**\n * 创建 faapi Schema Server\n * 通过 MCP 协议暴露路由信息供 LLM 查询\n */\nexport function createSchemaServer(routes: RouteManifest, rootDir: string): McpServer {\n const server = new McpServer({\n name: 'faapi-schema',\n version: '0.0.1',\n });\n\n // 缓存 route schemas\n let cachedSchemas: RouteInfo[] | null = null;\n\n function getSchemas(): RouteInfo[] {\n if (!cachedSchemas) {\n cachedSchemas = buildRouteSchemas(routes, rootDir);\n }\n return cachedSchemas;\n }\n\n // Tool: 列出所有路由\n server.tool(\n 'list_routes',\n '列出当前 faapi 应用的所有 API 路由,包括方法、路径、是否动态路由',\n {},\n () => {\n const schemas = getSchemas();\n const routesList = schemas.map((r) => ({\n method: r.method,\n path: r.path,\n isDynamic: r.isDynamic,\n filePath: r.filePath,\n }));\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify(routesList, null, 2),\n },\n ],\n };\n },\n );\n\n // Tool: 获取单个路由的详细 schema\n server.tool(\n 'get_route_schema',\n '获取指定路由的详细接口信息,包括输入参数的名称、类型、是否必填',\n {\n method: z.string().describe('HTTP 方法,如 GET、POST'),\n path: z.string().describe('路由路径,如 /auth/login'),\n },\n ({ method, path }) => {\n const schemas = getSchemas();\n const route = schemas.find((r) => r.method === method.toUpperCase() && r.path === path);\n\n if (!route) {\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({ error: `未找到路由 ${method.toUpperCase()} ${path}` }),\n },\n ],\n };\n }\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify(route, null, 2),\n },\n ],\n };\n },\n );\n\n // Tool: 获取所有路由的完整 schema(类似 OpenAPI)\n server.tool(\n 'get_api_schema',\n '获取当前应用所有接口的完整 schema,类似 OpenAPI 规范,包含每个路由的输入参数定义',\n {},\n () => {\n const schemas = getSchemas();\n const apiSchema: Record<string, unknown> = {};\n\n for (const route of schemas) {\n const key = `${route.method} ${route.path}`;\n apiSchema[key] = {\n method: route.method,\n path: route.path,\n isDynamic: route.isDynamic,\n inputs: route.inputs.map((input) => ({\n source: input.source,\n schemaName: input.schemaName,\n properties: input.properties,\n })),\n };\n }\n\n return {\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify(apiSchema, null, 2),\n },\n ],\n };\n },\n );\n\n return server;\n}\n\n/**\n * 启动 Schema Server(stdio 模式)\n */\nexport async function startSchemaServer(routes: RouteManifest, rootDir: string): Promise<void> {\n const server = createSchemaServer(routes, rootDir);\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n\n/**\n * faapi 插件入口\n *\n * 在 faapi.config.ts 中声明:\n * ```ts\n * export default {\n * plugins: ['@faapi/schema'],\n * } satisfies FaapiConfig;\n * ```\n */\nexport default {\n name: '@faapi/schema',\n setup(ctx: PluginContext) {\n if (!isSchemaEnabled()) {\n console.log('- Schema server disabled (FAAPI_SCHEMA=0 or production mode)');\n return;\n }\n console.log('- Schema server enabled (stdio)');\n // startSchemaServer 是异步的,但不阻塞启动流程\n void startSchemaServer(ctx.routes, ctx.rootDir);\n },\n} satisfies FaapiPlugin;\n","import path from 'node:path';\nimport {\n getSchemaProperties,\n getInputTypeForMethod,\n type RouteManifest,\n type RouteInfo,\n type RouteInputSchema,\n} from '@faapi/faapi';\n\n/**\n * 从路由清单生成接口描述信息\n *\n * 复用主包 schemaRegistry 已有的类型提取结果,避免重复 AST 分析。\n *\n * @param routes 路由清单\n * @param rootDir 根目录\n */\nexport function buildRouteSchemas(routes: RouteManifest, rootDir: string): RouteInfo[] {\n return routes.map((route) => {\n const absoluteFilePath = path.resolve(rootDir, route.filePath);\n const inputs = extractInputSchemas(absoluteFilePath, route.method, route);\n\n return {\n method: route.method,\n path: route.urlPath,\n filePath: route.filePath,\n isDynamic: route.isDynamic,\n inputs,\n };\n });\n}\n\n/**\n * 提取一个路由文件的所有输入 schema\n *\n * 直接查询 schemaRegistry,复用参数校验已提取的 PropertyType,\n * 不再重复执行 AST 分析。\n */\nfunction extractInputSchemas(\n filePath: string,\n method: string,\n route: { isDynamic: boolean; paramNames: string[] },\n): RouteInputSchema[] {\n const inputs: RouteInputSchema[] = [];\n\n // 主输入(query 或 body):从 registry 查询\n const inputType = getInputTypeForMethod(method);\n const schema = getSchemaProperties(filePath, method, inputType);\n\n if (schema) {\n inputs.push({\n source: inputType,\n schemaName: schema.schemaName,\n properties: schema.properties,\n });\n } else {\n // registry 无数据(不应发生,插件 setup 时 registry 已加载)\n inputs.push({\n source: inputType,\n schemaName: null,\n properties: [],\n });\n }\n\n // 动态路由参数\n if (route.isDynamic && route.paramNames.length > 0) {\n const paramsSchema = getSchemaProperties(filePath, method, 'params');\n\n // params 有类型声明时用类型信息,否则用 paramNames 兜底\n const paramsProps =\n paramsSchema && paramsSchema.schemaName\n ? paramsSchema.properties\n : route.paramNames.map((name) => ({\n name,\n type: 'string',\n required: true,\n }));\n\n inputs.push({\n source: 'params',\n schemaName: paramsSchema?.schemaName ?? null,\n properties: paramsProps,\n });\n }\n\n return inputs;\n}\n"],"mappings":"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","names":["path"]}
1
+ {"version":3,"sources":["../src/schemaServer.ts","../src/routeSchema.ts"],"sourcesContent":["/**\n * faapi Schema Server — 通过 MCP 协议以 resource 形式暴露路由 schema 供 AI 助手查询\n *\n * 基于 @faapi/mcp(纯手写 MCP Server SDK),不依赖 @modelcontextprotocol/sdk。\n * 通过 faapi 插件机制挂载到 /mcp 端点,AI 助手通过 Streamable HTTP 连接。\n *\n * 提供两种 MCP 能力:\n * - resources: 每个路由注册为静态 resource + by-method resourceTemplate\n * - completion: 为 resource template 的 method 参数提供补全\n *\n * 不注册 tool——查 schema 是读数据(resource 语义),不是执行动作(tool 语义)。\n * resource 还有 AI 客户端原生 UI、可缓存、支持 subscription 等 tool 做不到的优势。\n */\n\nimport { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { createMcpServer, createMcpNodeHandler, type McpServer } from '@faapi/mcp';\nimport type { RouteManifest, RouteInfo, FaapiPlugin, PluginContext } from '@faapi/faapi';\nimport { buildRouteSchemas } from './routeSchema';\n\n/** MCP 端点路径 */\nconst MCP_PATH = '/mcp';\n\n/** 合法 HTTP 方法集合(用于 template read 校验 + completion 候选值) */\nconst HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const;\n\n/**\n * 从 package.json 读取版本号\n *\n * 通过 import.meta.url 解析 ../package.json,dev 模式下指向源文件所在包根目录,\n * prod 模式下指向 dist/ 同级的包根目录。模块加载时一次性读取。\n */\nfunction readPackageVersion(): string {\n const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };\n return pkg.version ?? '0.0.0';\n}\n\n/** 构造路由 resource 的 URI */\nfunction routeToUri(route: { method: string; path: string }): string {\n return `faapi://route/${route.method}${route.path}`;\n}\n\n/**\n * 创建 Schema MCP Server\n *\n * 注册:\n * - 每个路由一个静态 resource(URI: faapi://route/{METHOD}{PATH})\n * - 1 个 resourceTemplate(faapi://routes/by-method/{method},按方法过滤)\n * - 1 个 completion(为 template 的 method 参数提供候选值)\n *\n * @param getRoutes 返回最新路由清单的 getter(dev reloadRoutes 后返回更新后的数组)\n * @param rootDir 项目根目录(用于 AST 分析解析源文件)\n */\nexport function createSchemaServer(getRoutes: () => RouteManifest, rootDir: string): McpServer {\n const mcp = createMcpServer({\n name: 'faapi-schema',\n version: readPackageVersion(),\n // 路由变化时主动推送 notifications/resources/list_changed\n resourcesListChanged: true,\n });\n\n // 缓存 route schemas:通过路由数组引用比较检测变更\n let cachedRoutes: RouteManifest | null = null;\n let cachedSchemas: RouteInfo[] | null = null;\n // 已注册的 resource URI 集合(变更时先 remove 再注册,避免重复注册抛错)\n const registeredUris = new Set<string>();\n // 标记是否已首次注册(首次无需推送 list_changed,因为没有 session)\n let resourceRegistered = false;\n\n function getSchemas(): RouteInfo[] {\n const currentRoutes = getRoutes();\n if (currentRoutes !== cachedRoutes || !cachedSchemas) {\n cachedRoutes = currentRoutes;\n cachedSchemas = buildRouteSchemas(currentRoutes, rootDir);\n registerResources(cachedSchemas);\n // 非首次注册时通知客户端列表变更\n if (resourceRegistered) {\n mcp.notifyResourcesListChanged();\n }\n resourceRegistered = true;\n }\n return cachedSchemas;\n }\n\n /** 重新注册所有静态 resource(先清空旧的,再注册新的) */\n function registerResources(schemas: RouteInfo[]): void {\n // 清空旧 resource\n for (const uri of registeredUris) {\n mcp.removeResource(uri);\n }\n registeredUris.clear();\n\n // 注册新 resource\n for (const route of schemas) {\n const uri = routeToUri(route);\n // 闭包捕获当前 route,避免循环变量引用问题\n const captured = route;\n mcp.resource(uri, {\n name: `${route.method} ${route.path}`,\n mimeType: 'application/json',\n read: async () => ({\n contents: [\n {\n uri,\n mimeType: 'application/json',\n text: JSON.stringify(captured, null, 2),\n },\n ],\n }),\n });\n registeredUris.add(uri);\n }\n }\n\n // ─── ResourceTemplate: 按方法过滤路由 ─────────────────\n mcp.resourceTemplate('faapi://routes/by-method/{method}', {\n name: 'routes-by-method',\n description: '按 HTTP 方法过滤路由,返回该方法的所有路由列表',\n read: async (uri, params) => {\n const method = (params.method ?? '').toUpperCase();\n // 不合法的方法返回空数组(不抛错,避免 InternalError;客户端拿不到路由自然知道方法无效)\n if (!HTTP_METHODS.includes(method as (typeof HTTP_METHODS)[number])) {\n return {\n contents: [\n {\n uri,\n mimeType: 'application/json',\n text: '[]',\n },\n ],\n };\n }\n const schemas = getSchemas().filter((r) => r.method === method);\n return {\n contents: [\n {\n uri,\n mimeType: 'application/json',\n text: JSON.stringify(schemas, null, 2),\n },\n ],\n };\n },\n });\n\n // ─── Completion: method 参数补全 ──────────────────────\n mcp.completion(\n { type: 'ref/resource', uri: 'faapi://routes/by-method/{method}' },\n 'method',\n (value) => {\n const upper = value.toUpperCase();\n return {\n values: HTTP_METHODS.filter((m) => m.startsWith(upper)),\n };\n },\n );\n\n // 触发首次 schemas 构建 + resource 注册\n // 必须在返回前调用,否则 resources/list 会返回空(懒加载不会在 list 时触发)\n getSchemas();\n\n return mcp;\n}\n\n/**\n * faapi 插件入口\n *\n * 在 faapi.config.ts 中声明:\n * ```ts\n * export default {\n * plugins: ['@faapi/schema'],\n * } satisfies FaapiConfig;\n * ```\n *\n * 插件在 /mcp 路径挂载 MCP 端点,AI 助手通过 Streamable HTTP 连接。\n */\nexport default {\n name: '@faapi/schema',\n setup(ctx: PluginContext) {\n const mcp = createSchemaServer(ctx.getRoutes, ctx.rootDir);\n const nodeHandler = createMcpNodeHandler(mcp);\n\n // 拦截 /mcp 路径的请求,交给 MCP handler 处理\n ctx.wrapHandler?.((original) => (req, res) => {\n const url = new URL(req.url ?? '/', 'http://localhost');\n if (url.pathname === MCP_PATH) {\n return nodeHandler(req, res);\n }\n return original(req, res);\n });\n\n console.log(`- Schema server enabled at ${MCP_PATH} (Streamable HTTP)`);\n },\n} satisfies FaapiPlugin;\n","/**\n * 从路由清单生成接口描述信息(含输入参数与响应类型)\n *\n * 直接调用主包 `collectRouteSchemaSources` 执行 AST 分析,提取每个路由 handler 的\n * 输入参数类型与返回类型,无需依赖运行时 schemaRegistry。\n *\n * 响应类型提取独立实现 Promise 解包逻辑,不修改主包 resolveTypeNode\n * (主包在运行时校验场景下遇到 Promise 会抛错,响应类型提取场景需解包取 T)。\n */\n\nimport ts from 'typescript';\nimport {\n collectRouteSchemaSources,\n createProgram,\n invalidateProgramCache,\n extractTypeInfo,\n resolveTypeNode,\n getInputTypeForMethod,\n SchemaExtractionError,\n type RouteManifest,\n type RouteInfo,\n type RouteInputSchema,\n type RouteOutputSchema,\n type RouteParamSchema,\n type RuntimeType,\n type PropertyType,\n} from '@faapi/faapi';\n\n/**\n * 从路由清单生成接口描述信息\n *\n * @param routes 路由清单\n * @param rootDir 项目根目录(用于解析源文件绝对路径)\n */\nexport function buildRouteSchemas(routes: RouteManifest, rootDir: string): RouteInfo[] {\n const { sources } = collectRouteSchemaSources(routes, rootDir);\n\n // 按 urlPath#schemaName 索引(schemaName = `${method}${inputType}`,如 GETQuery)\n const sourceMap = new Map<string, (typeof sources)[number]>();\n for (const source of sources) {\n sourceMap.set(`${source.urlPath}#${source.schemaName}`, source);\n }\n\n // 按 urlPath 索引源文件绝对路径(用于响应类型提取创建 program)\n const filePathMap = new Map<string, string>();\n for (const source of sources) {\n filePathMap.set(source.urlPath, source.filePath);\n }\n\n return routes.map((route) => {\n const inputs = extractInputSchemas(route, sourceMap);\n const output = extractOutputSchema(route, filePathMap.get(route.urlPath));\n return {\n method: route.method,\n path: route.urlPath,\n filePath: route.filePath,\n isDynamic: route.isDynamic,\n inputs,\n output,\n };\n });\n}\n\n/**\n * 提取一个路由的所有输入 schema\n *\n * 从 sourceMap 查询 AST 提取结果;无类型声明时 properties 为空。\n * 动态路由无 params 类型声明时,用 paramNames 兜底为 string[]。\n */\nfunction extractInputSchemas(\n route: { method: string; urlPath: string; isDynamic: boolean; paramNames: string[] },\n sourceMap: Map<\n string,\n { schemaName: string; typeInfo: { name: string; properties: PropertyType[] } | null }\n >,\n): RouteInputSchema[] {\n const inputs: RouteInputSchema[] = [];\n\n // 主输入(query 或 body)\n const inputType = getInputTypeForMethod(route.method);\n const schemaName = `${route.method.toUpperCase()}${capitalize(inputType)}`;\n const source = sourceMap.get(`${route.urlPath}#${schemaName}`);\n\n if (source?.typeInfo) {\n inputs.push({\n source: inputType,\n schemaName: source.typeInfo.name,\n properties: toParamSchemas(source.typeInfo.properties),\n });\n } else {\n // 无类型声明\n inputs.push({\n source: inputType,\n schemaName: null,\n properties: [],\n });\n }\n\n // 动态路由参数:collectRouteSchemaSources 不提取 params 类型,用 paramNames 兜底\n if (route.isDynamic && route.paramNames.length > 0) {\n inputs.push({\n source: 'params',\n schemaName: null,\n properties: route.paramNames.map((name) => ({ name, type: 'string', required: true })),\n });\n }\n\n return inputs;\n}\n\n/**\n * 提取路由的响应类型\n *\n * 流程:\n * 1. 用 TypeScript Compiler API 定位 handler 函数节点\n * 2. 读取 node.type(显式返回类型注解)\n * 3. 无注解 → 返回 null\n * 4. 解包 Promise<T>(若存在)→ 解析 T\n * 5. void/Promise<void> → 返回 null\n * 6. 解析失败 catch SchemaExtractionError → 降级返回 null(不阻断整条路由 schema 构建)\n *\n * @param route 路由信息\n * @param filePath 源文件绝对路径(已由 collectRouteSchemaSources 解析)\n */\nfunction extractOutputSchema(\n route: { method: string; urlPath: string },\n filePath: string | undefined,\n): RouteOutputSchema | null {\n if (!filePath) return null;\n\n try {\n const program = createProgram(filePath);\n const sourceFile = program.getSourceFile(filePath);\n if (!sourceFile) return null;\n\n const checker = program.getTypeChecker();\n\n // 定位 handler 函数声明(export function GET/POST/...)\n const handlerName = route.method.toUpperCase();\n let returnTypeNode: ts.TypeNode | undefined;\n ts.forEachChild(sourceFile, (node) => {\n if (returnTypeNode) return;\n if (ts.isFunctionDeclaration(node) && node.name?.text === handlerName) {\n returnTypeNode = node.type;\n }\n });\n\n if (!returnTypeNode) return null;\n\n // 解包 Promise<T>\n const unwrapped = unwrapPromise(returnTypeNode);\n if (unwrapped === null) return null; // void\n\n // 解析解包后的类型\n const runtimeType = resolveTypeNode(unwrapped, checker);\n return runtimeTypeToOutput(unwrapped, runtimeType, program, filePath, checker);\n } catch (err) {\n // 解析失败降级为 null,不阻断整条路由 schema 构建\n if (err instanceof SchemaExtractionError) return null;\n throw err;\n }\n}\n\n/**\n * 解包 Promise<T> → T\n *\n * - Promise<T> → T\n * - Promise<void> → null(表示无响应类型)\n * - 非 Promise 类型 → 原样返回\n *\n * 仅解包一层,不递归(Promise<Promise<T>> 实际不会出现)。\n */\nfunction unwrapPromise(typeNode: ts.TypeNode): ts.TypeNode | null {\n if (!ts.isTypeReferenceNode(typeNode)) return typeNode;\n\n const typeName = typeNode.typeName.getText();\n if (typeName !== 'Promise') return typeNode;\n\n // Promise 无类型参数 → 视为 Promise<any> → 原样返回(any 由 resolveTypeNode 处理)\n const arg = typeNode.typeArguments?.[0];\n if (!arg) return typeNode;\n\n // Promise<void> → null\n if (ts.isTypeNode(arg) && arg.kind === ts.SyntaxKind.VoidKeyword) return null;\n\n return arg;\n}\n\n/**\n * 将 RuntimeType + 原始类型节点转换为 RouteOutputSchema\n *\n * - 命名类型引用(TypeReference)→ 调 extractTypeInfo 提取完整结构 → properties 来自 typeInfo\n * - 数组类型 T[](TypeReference Array<T>)→ 取元素类型名,提取元素结构\n * - 内联对象字面量(TypeLiteralNode)→ 直接从 runtimeType.properties 提取\n * - 其他(基础类型/联合/元组等)→ properties 为空,schemaName 为 null\n */\nfunction runtimeTypeToOutput(\n typeNode: ts.TypeNode,\n runtimeType: RuntimeType,\n program: ts.Program,\n filePath: string,\n _checker: ts.TypeChecker,\n): RouteOutputSchema {\n // 内联对象字面量 → 直接从 runtimeType 提取 properties\n if (ts.isTypeLiteralNode(typeNode)) {\n if (runtimeType.kind === 'object') {\n return {\n schemaName: null,\n properties: toParamSchemas(runtimeType.properties),\n };\n }\n }\n\n // 数组类型 T[](ArrayTypeNode)→ 取元素类型提取结构\n if (ts.isArrayTypeNode(typeNode) && runtimeType.kind === 'array') {\n const element = runtimeType.element;\n // 元素是命名类型引用 → 提取元素结构\n if (element.kind === 'ref') {\n const typeInfo = extractTypeInfo(program, filePath, element.name);\n if (typeInfo) {\n return {\n schemaName: element.name,\n properties: toParamSchemas(typeInfo.properties),\n };\n }\n }\n // 元素已被 checker 展开为 object(resolveTypeNode 默认行为)→ 尝试从 AST 节点拿命名\n if (element.kind === 'object') {\n const elementNode = typeNode.elementType;\n if (elementNode && ts.isTypeReferenceNode(elementNode)) {\n const elementName = elementNode.typeName.getText();\n const typeInfo = extractTypeInfo(program, filePath, elementName);\n if (typeInfo) {\n return {\n schemaName: elementName,\n properties: toParamSchemas(typeInfo.properties),\n };\n }\n }\n // 内联对象数组({ id: number }[])\n return {\n schemaName: null,\n properties: toParamSchemas(element.properties),\n };\n }\n // 数组元素非命名类型(如 string[]、number[])\n return { schemaName: null, properties: [] };\n }\n\n // 命名类型引用 → 提取完整结构\n if (ts.isTypeReferenceNode(typeNode)) {\n const typeName = typeNode.typeName.getText();\n\n // 数组类型 Array<T>(TypeReference Array<T>)→ 取元素类型名提取结构\n if (runtimeType.kind === 'array') {\n const element = runtimeType.element;\n if (element.kind === 'ref') {\n const typeInfo = extractTypeInfo(program, filePath, element.name);\n if (typeInfo) {\n return {\n schemaName: element.name,\n properties: toParamSchemas(typeInfo.properties),\n };\n }\n }\n // 数组元素是内联对象({ id: number }[])\n if (element.kind === 'object') {\n return {\n schemaName: null,\n properties: toParamSchemas(element.properties),\n };\n }\n // 数组元素非命名类型(如 string[]、number[])\n return { schemaName: null, properties: [] };\n }\n\n // 命名类型引用(interface/type alias)\n if (runtimeType.kind === 'ref' || runtimeType.kind === 'object') {\n const typeInfo = extractTypeInfo(program, filePath, typeName);\n if (typeInfo) {\n return {\n schemaName: typeName,\n properties: toParamSchemas(typeInfo.properties),\n };\n }\n }\n }\n\n // 其他类型(基础类型/联合/元组等)\n return { schemaName: null, properties: [] };\n}\n\n/**\n * 首字母大写\n */\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n\n/**\n * 将 PropertyType[] 转换为 RouteParamSchema[]\n */\nfunction toParamSchemas(properties: PropertyType[]): RouteParamSchema[] {\n return properties.map((prop) => ({\n name: prop.name,\n type: runtimeTypeToString(prop.type),\n required: !prop.optional,\n }));\n}\n\n/**\n * 将 RuntimeType 转为可读字符串\n */\nfunction runtimeTypeToString(type: RuntimeType): string {\n switch (type.kind) {\n case 'string':\n case 'number':\n case 'boolean':\n case 'bigint':\n case 'null':\n case 'undefined':\n case 'date':\n return type.kind;\n case 'literal':\n return JSON.stringify(type.value);\n case 'array':\n return `${runtimeTypeToString(type.element)}[]`;\n case 'tuple':\n return `[${type.elements.map((e) => (e.rest ? '...' : '') + runtimeTypeToString(e.type) + (e.optional ? '?' : '')).join(', ')}]`;\n case 'object':\n return 'object';\n case 'union':\n return type.members.map(runtimeTypeToString).join(' | ');\n case 'record':\n return `Record<${runtimeTypeToString(type.key)}, ${runtimeTypeToString(type.value)}>`;\n case 'ref':\n return type.name;\n case 'any':\n case 'unknown':\n return 'unknown';\n default:\n return 'unknown';\n }\n}\n\n// 显式导出 invalidateProgramCache 便于测试清理缓存\nexport { invalidateProgramCache };\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faapi/schema",
3
- "version": "0.0.0-canary.4ab8d97",
3
+ "version": "0.0.0-canary.4e89b9b",
4
4
  "description": "Schema introspection for faapi — expose API schema to AI assistants via MCP",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,20 +17,22 @@
17
17
  "engines": {
18
18
  "node": ">=24"
19
19
  },
20
- "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.29.0",
22
- "zod": "^4.4.3",
23
- "@faapi/faapi": "0.0.0-canary.4ab8d97"
24
- },
20
+ "dependencies": {},
25
21
  "devDependencies": {
26
22
  "@types/node": "^22.15.0",
27
23
  "@vitest/coverage-v8": "3",
28
24
  "tsup": "^8.4.0",
29
25
  "typescript": "^5.7.0",
30
- "vitest": "^3.1.0"
26
+ "vitest": "^3.1.0",
27
+ "zod": "^4.4.3",
28
+ "@faapi/faapi": "0.0.0-canary.4e89b9b",
29
+ "@faapi/mcp": "0.0.0-canary.4e89b9b"
31
30
  },
32
31
  "peerDependencies": {
33
- "typescript": ">=5.7.0"
32
+ "typescript": ">=5.7.0",
33
+ "zod": "^4.4.3",
34
+ "@faapi/faapi": "0.0.0-canary.4e89b9b",
35
+ "@faapi/mcp": "0.0.0-canary.4e89b9b"
34
36
  },
35
37
  "peerDependenciesMeta": {
36
38
  "typescript": {