feat: 优化 MCP 工具响应与描述

- 改写 5 个工具的描述,明确调用顺序、何时使用、返回结构
- list_endpoints 与 search_endpoints 支持 cursor/limit 分页
- get_endpoint_detail 将 OpenAPI schema 扁平化为字段列表(2 层展开)
- 抽取 jsonOk/textOk/textError 响应辅助函数
- 错误响应附带样例 ID 与可用模块提示,方便 LLM 自我纠正

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 11:46:41 +08:00
parent 71c604411d
commit 8d7692a42d
8 changed files with 432 additions and 58 deletions

View File

@@ -0,0 +1,14 @@
type ToolContent = { type: 'text'; text: string };
type ToolResult = { content: ToolContent[]; isError?: true };
export function jsonOk(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
}
export function textOk(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
export function textError(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true };
}

View File

@@ -0,0 +1,221 @@
type AnySchema = Record<string, any> | null | undefined;
export type FlatField = {
name: string;
type: string;
required: boolean;
description?: string;
example?: unknown;
enum?: unknown[];
format?: string;
/** Object-typed field — child fields expanded one more level. */
fields?: FlatField[];
/** Array-typed field whose items are objects — element fields expanded one level. */
items?: FlatField[];
/** True when the field has nested structure that was NOT expanded (depth limit reached). */
nested?: true;
};
export type FlatSchema =
| { kind: 'object'; fields: FlatField[]; additionalProperties?: true }
| { kind: 'array'; items: FlatSchema }
| { kind: 'primitive'; type: string; format?: string; enum?: unknown[]; example?: unknown }
| { kind: 'variants'; variants: FlatSchema[] }
| { kind: 'unknown' };
export type FlatParameter = {
name: string;
in: string;
required: boolean;
type: string;
description?: string;
example?: unknown;
enum?: unknown[];
format?: string;
};
export type FlatRequestBody = {
contentType: string;
required: boolean;
schema: FlatSchema;
} | null;
export type FlatResponse = {
status: string;
description?: string;
contentType?: string;
schema?: FlatSchema;
};
const MAX_DEPTH = 1;
function describeType(s: AnySchema): string {
if (!s) return 'unknown';
if (Array.isArray(s.type)) return s.type.join('|');
if (typeof s.type === 'string') {
if (s.type === 'array' && s.items?.type) return `array<${describeType(s.items)}>`;
return s.type;
}
if (s.properties || s.additionalProperties) return 'object';
if (s.oneOf || s.anyOf) return 'variants';
if (s.allOf) return 'object';
return 'unknown';
}
function mergeAllOf(schemas: AnySchema[]): Record<string, any> {
const merged: Record<string, any> = { type: 'object', properties: {}, required: [] };
for (const s of schemas) {
if (!s) continue;
if (s.properties) Object.assign(merged.properties, s.properties);
if (Array.isArray(s.required)) merged.required.push(...s.required);
if (s.description && !merged.description) merged.description = s.description;
}
merged.required = Array.from(new Set(merged.required));
return merged;
}
function resolveSchema(s: AnySchema): AnySchema {
if (!s || typeof s !== 'object') return s;
if (Array.isArray(s.allOf) && s.allOf.length > 0) return resolveSchema(mergeAllOf(s.allOf));
return s;
}
function buildFields(schema: AnySchema, depth: number): FlatField[] {
const resolved = resolveSchema(schema);
if (!resolved || typeof resolved !== 'object') return [];
const required = new Set<string>(Array.isArray(resolved.required) ? resolved.required : []);
const canExpand = depth < MAX_DEPTH;
return Object.entries(resolved.properties || {}).map(([name, propRaw]) => {
const prop = resolveSchema(propRaw as AnySchema);
const field: FlatField = {
name,
type: describeType(prop),
required: required.has(name),
};
if (prop?.description) field.description = String(prop.description);
if (prop?.example !== undefined) field.example = prop.example;
if (Array.isArray(prop?.enum)) field.enum = prop.enum;
if (prop?.format) field.format = String(prop.format);
const itemsSchema = prop?.items ? resolveSchema(prop.items) : null;
const isObj = !!(prop && (prop.properties || prop.type === 'object'));
const isArrayOfObjects = !!(itemsSchema && (itemsSchema.properties || itemsSchema.type === 'object'));
if (isObj) {
if (canExpand) field.fields = buildFields(prop, depth + 1);
else field.nested = true;
} else if (isArrayOfObjects) {
if (canExpand) field.items = buildFields(itemsSchema, depth + 1);
else field.nested = true;
}
return field;
});
}
export function flattenSchema(schema: AnySchema, depth = 0): FlatSchema {
if (!schema || typeof schema !== 'object') return { kind: 'unknown' };
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
return flattenSchema(mergeAllOf(schema.allOf), depth);
}
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
const list = (schema.oneOf || schema.anyOf) as AnySchema[];
return { kind: 'variants', variants: list.map((s) => flattenSchema(s, depth)) };
}
if (schema.type === 'array' || schema.items) {
const items = depth >= MAX_DEPTH
? ({ kind: 'primitive', type: describeType(schema.items) } as FlatSchema)
: flattenSchema(schema.items, depth + 1);
return { kind: 'array', items };
}
const isObjectLike = schema.type === 'object' || schema.properties || schema.additionalProperties;
if (isObjectLike) {
const fields = buildFields(schema, depth);
const out: Extract<FlatSchema, { kind: 'object' }> = { kind: 'object', fields };
if (schema.additionalProperties && Object.keys(schema.properties || {}).length === 0) {
out.additionalProperties = true;
}
return out;
}
const primitive: Extract<FlatSchema, { kind: 'primitive' }> = {
kind: 'primitive',
type: describeType(schema),
};
if (schema.format) primitive.format = String(schema.format);
if (Array.isArray(schema.enum)) primitive.enum = schema.enum;
if (schema.example !== undefined) primitive.example = schema.example;
return primitive;
}
export function flattenParameters(params: unknown): FlatParameter[] {
if (!Array.isArray(params)) return [];
return params.map((pRaw) => {
const p = pRaw as Record<string, any>;
const inlineSchema: AnySchema = p.schema ?? p;
const param: FlatParameter = {
name: String(p.name ?? ''),
in: String(p.in ?? 'query'),
required: !!p.required,
type: describeType(inlineSchema),
};
if (p.description) param.description = String(p.description);
const example = p.example ?? inlineSchema?.example;
if (example !== undefined) param.example = example;
const enumVals = inlineSchema?.enum ?? p.enum;
if (Array.isArray(enumVals)) param.enum = enumVals;
const format = inlineSchema?.format ?? p.format;
if (format) param.format = String(format);
return param;
});
}
export function flattenRequestBody(body: unknown): FlatRequestBody {
if (!body || typeof body !== 'object') return null;
const b = body as Record<string, any>;
// OpenAPI 3 style
if (b.content && typeof b.content === 'object') {
const contentType = Object.keys(b.content)[0];
if (!contentType) return null;
return {
contentType,
required: !!b.required,
schema: flattenSchema(b.content[contentType]?.schema),
};
}
// Swagger 2 converted style: { schema }
if (b.schema) {
return {
contentType: 'application/json',
required: !!b.required,
schema: flattenSchema(b.schema),
};
}
return null;
}
export function flattenResponses(responses: unknown): FlatResponse[] {
if (!responses || typeof responses !== 'object') return [];
return Object.entries(responses as Record<string, any>).map(([status, raw]) => {
const r = raw as Record<string, any>;
const out: FlatResponse = { status };
if (r?.description) out.description = String(r.description);
// OpenAPI 3
if (r?.content && typeof r.content === 'object') {
const contentType = Object.keys(r.content)[0];
if (contentType) {
out.contentType = contentType;
out.schema = flattenSchema(r.content[contentType]?.schema);
}
} else if (r?.schema) {
// Swagger 2
out.contentType = 'application/json';
out.schema = flattenSchema(r.schema);
}
return out;
});
}

View File

@@ -18,40 +18,83 @@ export function createMcpServer(projectId: string, clientIp: string = ''): McpSe
server.tool(
'get_project_overview',
'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
[
'Returns a high-level snapshot of this API project: name, version, baseUrl, totalEndpoints, totalModules, and a compact module list (id + name + endpointCount, no descriptions).',
'Call this FIRST when you have no prior context about the API. Cheap (~500B).',
'Use list_modules instead if you need module descriptions to choose between modules.',
'Next steps: pick a moduleId and call list_endpoints, or jump straight to search_endpoints with a keyword.',
].join(' '),
{},
async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
);
server.tool(
'list_modules',
'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
[
'Returns ALL modules with their descriptions and endpoint counts. Use this when get_project_overview did not give you enough information to choose a module (i.e., you need the description text to disambiguate).',
'Returns { total, modules: [{ id, name, description, endpointCount }] }.',
'Do not call this if you already know which module to drill into — go straight to list_endpoints.',
].join(' '),
{},
async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
);
server.tool(
'list_endpoints',
'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
{ moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') },
async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
[
'Returns endpoint summaries inside a single module: { moduleId, moduleName, total, nextCursor, endpoints: [{ id, method, path, summary, deprecated }] }.',
'Paginated: pass `cursor` (the previous response\'s `nextCursor`) to fetch the next page; pass `limit` (default 50, max 200) to control page size. `nextCursor` is null when there are no more results.',
'Use search_endpoints instead if you have a keyword and don\'t want to paginate through everything.',
'Then call get_endpoint_detail with an endpoint id to see parameters/request/response shapes.',
].join(' '),
{
moduleId: z.string().describe('The module id from get_project_overview or list_modules. Example: "clx9k2abc0001..."'),
cursor: z.string().optional().describe('Pagination cursor: pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.'),
limit: z.number().int().min(1).max(200).optional().describe('Page size, default 50, max 200. Increase only if you actually need more endpoints in one call.'),
},
async ({ moduleId, cursor, limit }) =>
logMcpCall(
ctx('list_endpoints', { moduleId, cursor, limit }),
() => listEndpoints(projectId, moduleId, { cursor, limit }),
),
);
server.tool(
'get_endpoint_detail',
'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.',
{ endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') },
async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
[
'Returns the full callable contract of one endpoint, with OpenAPI schemas already FLATTENED into field lists you can read without parsing JSON Schema.',
'Returns: { id, method, path, summary, description, operationId, moduleId, moduleName, deprecated, parameters: [{ name, in, required, type, description?, example?, enum?, format? }], requestBody: { contentType, required, schema } | null, responses: [{ status, description?, contentType?, schema? }] }.',
'A `schema` is one of: { kind: "object", fields: [<field>] } | { kind: "array", items: <schema> } | { kind: "primitive", type, ... } | { kind: "variants", variants: [<schema>] }. Each field is { name, type, required, description?, example?, enum?, format?, fields? (nested object expanded one more level), items? (array element fields expanded one level), nested? (true if structure goes deeper than expanded) }. The expansion goes 2 levels deep total — that is enough to construct a request body in 99% of cases without further calls.',
'Call this when you are about to actually use an endpoint and need its parameters/body/response shape.',
].join(' '),
{
endpointId: z.string().describe('The endpoint id from list_endpoints or search_endpoints. Example: "clx9k2def0042..."'),
},
async ({ endpointId }) =>
logMcpCall(
ctx('get_endpoint_detail', { endpointId }),
() => getEndpointDetail(projectId, endpointId),
),
);
server.tool(
'search_endpoints',
'Search for endpoints by keyword. Searches across path, summary, description, and operationId. Optionally filter by module. Returns matching endpoint summaries.',
[
'Case-insensitive substring search across endpoint path, summary, description, and operationId.',
'Returns: { total, hasMore, results: [{ id, method, path, summary, deprecated, moduleId, moduleName }], hint? }.',
'Prefer this over list_endpoints when you have any keyword from the user request (a noun, a path segment, an action verb). Default page size is 20 (max 100). When `hasMore: true`, narrow the keyword or scope to a moduleId rather than just raising the limit.',
'Then call get_endpoint_detail with one of the returned ids.',
].join(' '),
{
keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
keyword: z.string().min(1).describe('Substring to match. Examples: "user", "/orders", "createPet", "checkout". Single word works best; multi-word phrases match literally.'),
moduleId: z.string().optional().describe('Optional: limit search to one module id. Omit to search across the whole project.'),
limit: z.number().int().min(1).max(100).optional().describe('Max results to return, default 20, max 100.'),
},
async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)),
async ({ keyword, moduleId, limit }) =>
logMcpCall(
ctx('search_endpoints', { keyword, moduleId, limit }),
() => searchEndpoints(projectId, keyword, { moduleId, limit }),
),
);
return server;

View File

@@ -1,22 +1,38 @@
import { prisma } from '@agent-fox/shared';
import { flattenParameters, flattenRequestBody, flattenResponses } from '../lib/schema-flatten.js';
import { jsonOk, textError } from '../lib/responses.js';
export async function getEndpointDetail(projectId: string, endpointId: string) {
const endpoint = await prisma.endpoint.findFirst({
where: { id: endpointId, projectId },
include: { module: { select: { name: true } } },
include: { module: { select: { id: true, name: true } } },
});
if (!endpoint) {
return { content: [{ type: 'text' as const, text: `Endpoint "${endpointId}" not found. Use list_endpoints to see available endpoints.` }], isError: true };
const sample = await prisma.endpoint.findMany({
where: { projectId },
select: { id: true, method: true, path: true },
orderBy: { id: 'asc' },
take: 5,
});
const hint = sample.length > 0
? ` Example endpoint IDs in this project: ${sample.map((e) => `${e.id} (${e.method} ${e.path})`).join(', ')}.`
: ' This project has no endpoints yet — verify the OpenAPI import succeeded.';
return textError(`Endpoint id "${endpointId}" not found in this project. Use search_endpoints with a keyword from the URL or summary, or list_endpoints to browse a module.${hint}`);
}
const detail = {
id: endpoint.id, method: endpoint.method, path: endpoint.path,
summary: endpoint.summary, description: endpoint.description,
operationId: endpoint.operationId, moduleName: endpoint.module.name,
parameters: endpoint.parameters, requestBody: endpoint.requestBody,
responses: endpoint.responses, deprecated: endpoint.deprecated,
};
return { content: [{ type: 'text' as const, text: JSON.stringify(detail, null, 2) }] };
return jsonOk({
id: endpoint.id,
method: endpoint.method,
path: endpoint.path,
summary: endpoint.summary,
description: endpoint.description,
operationId: endpoint.operationId,
moduleId: endpoint.module.id,
moduleName: endpoint.module.name,
deprecated: endpoint.deprecated,
parameters: flattenParameters(endpoint.parameters),
requestBody: flattenRequestBody(endpoint.requestBody),
responses: flattenResponses(endpoint.responses),
});
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@agent-fox/shared';
import { jsonOk, textError } from '../lib/responses.js';
export async function getProjectOverview(projectId: string) {
const project = await prisma.project.findUnique({
@@ -14,19 +15,18 @@ export async function getProjectOverview(projectId: string) {
});
if (!project) {
return { content: [{ type: 'text' as const, text: 'Project not found' }], isError: true };
return textError('Project not found. The project may have been deleted; ask the user to verify the MCP URL points to an existing project.');
}
const overview = {
return jsonOk({
name: project.name,
description: project.description,
version: project.openApiVersion,
baseUrl: project.baseUrl,
totalEndpoints: project._count.endpoints,
totalModules: project.modules.length,
modules: project.modules.map((m) => ({
id: m.id, name: m.name, endpointCount: m._count.endpoints,
})),
};
return { content: [{ type: 'text' as const, text: JSON.stringify(overview, null, 2) }] };
});
}

View File

@@ -1,16 +1,60 @@
import { prisma } from '@agent-fox/shared';
import { jsonOk, textError, textOk } from '../lib/responses.js';
export async function listEndpoints(projectId: string, moduleId: string) {
const mod = await prisma.module.findFirst({ where: { id: moduleId, projectId } });
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export type ListEndpointsOptions = { cursor?: string; limit?: number };
export async function listEndpoints(
projectId: string,
moduleId: string,
opts: ListEndpointsOptions = {},
) {
const mod = await prisma.module.findFirst({
where: { id: moduleId, projectId },
select: { id: true, name: true },
});
if (!mod) {
return { content: [{ type: 'text' as const, text: `Module "${moduleId}" not found. Use get_project_overview or list_modules to see available modules.` }], isError: true };
const sample = await prisma.module.findMany({
where: { projectId },
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' },
take: 5,
});
const hint = sample.length > 0
? ` Available modules: ${sample.map((m) => `${m.name} (${m.id})`).join(', ')}.`
: ' This project has no modules — verify the OpenAPI import succeeded.';
return textError(`Module id "${moduleId}" not found in this project. Use list_modules for the full list.${hint}`);
}
const endpoints = await prisma.endpoint.findMany({
where: { projectId, moduleId },
select: { id: true, method: true, path: true, summary: true, deprecated: true },
orderBy: [{ path: 'asc' }, { method: 'asc' }],
});
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
return { content: [{ type: 'text' as const, text: JSON.stringify(endpoints, null, 2) }] };
// Pagination orders by id (cuid, monotonic enough for stable cursors); LLM
// gets the path/method on every row, so display order doesn't really matter.
const [total, rows] = await Promise.all([
prisma.endpoint.count({ where: { projectId, moduleId } }),
prisma.endpoint.findMany({
where: { projectId, moduleId },
select: { id: true, method: true, path: true, summary: true, deprecated: true },
orderBy: { id: 'asc' },
take: take + 1,
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
}),
]);
if (total === 0) {
return textOk(`Module "${mod.name}" exists but contains 0 endpoints. This usually means the OpenAPI import did not group any endpoints into this module — check the source spec's tags or path prefixes.`);
}
const hasMore = rows.length > take;
const endpoints = hasMore ? rows.slice(0, take) : rows;
return jsonOk({
moduleId: mod.id,
moduleName: mod.name,
total,
nextCursor: hasMore ? endpoints[endpoints.length - 1].id : null,
endpoints,
});
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@agent-fox/shared';
import { jsonOk, textOk } from '../lib/responses.js';
export async function listModules(projectId: string) {
const modules = await prisma.module.findMany({
@@ -7,9 +8,17 @@ export async function listModules(projectId: string) {
orderBy: { sortOrder: 'asc' },
});
const result = modules.map((m) => ({
id: m.id, name: m.name, description: m.description, endpointCount: m._count.endpoints,
}));
if (modules.length === 0) {
return textOk('This project has 0 modules. The OpenAPI import may have failed or the spec contains no operations. Ask the project owner to re-import a valid OpenAPI document.');
}
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
return jsonOk({
total: modules.length,
modules: modules.map((m) => ({
id: m.id,
name: m.name,
description: m.description,
endpointCount: m._count.endpoints,
})),
});
}

View File

@@ -1,8 +1,18 @@
import { prisma } from '@agent-fox/shared';
import { jsonOk, textOk } from '../lib/responses.js';
export async function searchEndpoints(projectId: string, keyword: string, moduleId?: string) {
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
export type SearchEndpointsOptions = { moduleId?: string; limit?: number };
export async function searchEndpoints(
projectId: string,
keyword: string,
opts: SearchEndpointsOptions = {},
) {
const where: any = { projectId };
if (moduleId) where.moduleId = moduleId;
if (opts.moduleId) where.moduleId = opts.moduleId;
where.OR = [
{ path: { contains: keyword, mode: 'insensitive' } },
@@ -11,24 +21,41 @@ export async function searchEndpoints(projectId: string, keyword: string, module
{ operationId: { contains: keyword, mode: 'insensitive' } },
];
const endpoints = await prisma.endpoint.findMany({
where,
select: {
id: true, method: true, path: true, summary: true, deprecated: true,
module: { select: { name: true } },
},
orderBy: [{ path: 'asc' }, { method: 'asc' }],
take: 20,
});
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
if (endpoints.length === 0) {
return { content: [{ type: 'text' as const, text: `No endpoints found matching "${keyword}". Try a different keyword or use list_modules to browse.` }] };
const [total, rows] = await Promise.all([
prisma.endpoint.count({ where }),
prisma.endpoint.findMany({
where,
select: {
id: true, method: true, path: true, summary: true, deprecated: true,
module: { select: { id: true, name: true } },
},
orderBy: [{ path: 'asc' }, { method: 'asc' }, { id: 'asc' }],
take,
}),
]);
if (total === 0) {
const moduleHint = opts.moduleId ? ' (within the specified module)' : '';
return textOk(`No endpoints match "${keyword}"${moduleHint}. Try a shorter or different keyword (search covers path, summary, description, operationId), or omit moduleId to search across all modules. Use list_modules to browse available modules.`);
}
const result = endpoints.map((e) => ({
id: e.id, method: e.method, path: e.path, summary: e.summary,
moduleName: e.module.name, deprecated: e.deprecated,
const results = rows.map((e) => ({
id: e.id,
method: e.method,
path: e.path,
summary: e.summary,
deprecated: e.deprecated,
moduleId: e.module.id,
moduleName: e.module.name,
}));
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
const hasMore = total > results.length;
return jsonOk({
total,
hasMore,
results,
...(hasMore && { hint: `Showing first ${results.length} of ${total}. Narrow with a more specific keyword, scope to a moduleId, or raise limit (max ${MAX_LIMIT}).` }),
});
}