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:
14
packages/mcp/src/lib/responses.ts
Normal file
14
packages/mcp/src/lib/responses.ts
Normal 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 };
|
||||||
|
}
|
||||||
221
packages/mcp/src/lib/schema-flatten.ts
Normal file
221
packages/mcp/src/lib/schema-flatten.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,40 +18,83 @@ export function createMcpServer(projectId: string, clientIp: string = ''): McpSe
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'get_project_overview',
|
'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)),
|
async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_modules',
|
'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)),
|
async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_endpoints',
|
'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.') },
|
'Returns endpoint summaries inside a single module: { moduleId, moduleName, total, nextCursor, endpoints: [{ id, method, path, summary, deprecated }] }.',
|
||||||
async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
|
'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(
|
server.tool(
|
||||||
'get_endpoint_detail',
|
'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.') },
|
'Returns the full callable contract of one endpoint, with OpenAPI schemas already FLATTENED into field lists you can read without parsing JSON Schema.',
|
||||||
async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
|
'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(
|
server.tool(
|
||||||
'search_endpoints',
|
'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.'),
|
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 module ID to limit search scope. Omit to search all modules.'),
|
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;
|
return server;
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
import { prisma } from '@agent-fox/shared';
|
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) {
|
export async function getEndpointDetail(projectId: string, endpointId: string) {
|
||||||
const endpoint = await prisma.endpoint.findFirst({
|
const endpoint = await prisma.endpoint.findFirst({
|
||||||
where: { id: endpointId, projectId },
|
where: { id: endpointId, projectId },
|
||||||
include: { module: { select: { name: true } } },
|
include: { module: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!endpoint) {
|
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 = {
|
return jsonOk({
|
||||||
id: endpoint.id, method: endpoint.method, path: endpoint.path,
|
id: endpoint.id,
|
||||||
summary: endpoint.summary, description: endpoint.description,
|
method: endpoint.method,
|
||||||
operationId: endpoint.operationId, moduleName: endpoint.module.name,
|
path: endpoint.path,
|
||||||
parameters: endpoint.parameters, requestBody: endpoint.requestBody,
|
summary: endpoint.summary,
|
||||||
responses: endpoint.responses, deprecated: endpoint.deprecated,
|
description: endpoint.description,
|
||||||
};
|
operationId: endpoint.operationId,
|
||||||
|
moduleId: endpoint.module.id,
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(detail, null, 2) }] };
|
moduleName: endpoint.module.name,
|
||||||
|
deprecated: endpoint.deprecated,
|
||||||
|
parameters: flattenParameters(endpoint.parameters),
|
||||||
|
requestBody: flattenRequestBody(endpoint.requestBody),
|
||||||
|
responses: flattenResponses(endpoint.responses),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@agent-fox/shared';
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { jsonOk, textError } from '../lib/responses.js';
|
||||||
|
|
||||||
export async function getProjectOverview(projectId: string) {
|
export async function getProjectOverview(projectId: string) {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -14,19 +15,18 @@ export async function getProjectOverview(projectId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
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,
|
name: project.name,
|
||||||
description: project.description,
|
description: project.description,
|
||||||
version: project.openApiVersion,
|
version: project.openApiVersion,
|
||||||
baseUrl: project.baseUrl,
|
baseUrl: project.baseUrl,
|
||||||
totalEndpoints: project._count.endpoints,
|
totalEndpoints: project._count.endpoints,
|
||||||
|
totalModules: project.modules.length,
|
||||||
modules: project.modules.map((m) => ({
|
modules: project.modules.map((m) => ({
|
||||||
id: m.id, name: m.name, endpointCount: m._count.endpoints,
|
id: m.id, name: m.name, endpointCount: m._count.endpoints,
|
||||||
})),
|
})),
|
||||||
};
|
});
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(overview, null, 2) }] };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,60 @@
|
|||||||
import { prisma } from '@agent-fox/shared';
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { jsonOk, textError, textOk } from '../lib/responses.js';
|
||||||
|
|
||||||
export async function listEndpoints(projectId: string, moduleId: string) {
|
const DEFAULT_LIMIT = 50;
|
||||||
const mod = await prisma.module.findFirst({ where: { id: moduleId, projectId } });
|
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) {
|
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({
|
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||||
|
|
||||||
|
// 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 },
|
where: { projectId, moduleId },
|
||||||
select: { id: true, method: true, path: true, summary: true, deprecated: true },
|
select: { id: true, method: true, path: true, summary: true, deprecated: true },
|
||||||
orderBy: [{ path: 'asc' }, { method: 'asc' }],
|
orderBy: { id: 'asc' },
|
||||||
});
|
take: take + 1,
|
||||||
|
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(endpoints, null, 2) }] };
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@agent-fox/shared';
|
import { prisma } from '@agent-fox/shared';
|
||||||
|
import { jsonOk, textOk } from '../lib/responses.js';
|
||||||
|
|
||||||
export async function listModules(projectId: string) {
|
export async function listModules(projectId: string) {
|
||||||
const modules = await prisma.module.findMany({
|
const modules = await prisma.module.findMany({
|
||||||
@@ -7,9 +8,17 @@ export async function listModules(projectId: string) {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = modules.map((m) => ({
|
if (modules.length === 0) {
|
||||||
id: m.id, name: m.name, description: m.description, endpointCount: m._count.endpoints,
|
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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { prisma } from '@agent-fox/shared';
|
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 };
|
const where: any = { projectId };
|
||||||
if (moduleId) where.moduleId = moduleId;
|
if (opts.moduleId) where.moduleId = opts.moduleId;
|
||||||
|
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ path: { contains: keyword, mode: 'insensitive' } },
|
{ path: { contains: keyword, mode: 'insensitive' } },
|
||||||
@@ -11,24 +21,41 @@ export async function searchEndpoints(projectId: string, keyword: string, module
|
|||||||
{ operationId: { contains: keyword, mode: 'insensitive' } },
|
{ operationId: { contains: keyword, mode: 'insensitive' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const endpoints = await prisma.endpoint.findMany({
|
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||||
|
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
prisma.endpoint.count({ where }),
|
||||||
|
prisma.endpoint.findMany({
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true, method: true, path: true, summary: true, deprecated: true,
|
id: true, method: true, path: true, summary: true, deprecated: true,
|
||||||
module: { select: { name: true } },
|
module: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ path: 'asc' }, { method: 'asc' }],
|
orderBy: [{ path: 'asc' }, { method: 'asc' }, { id: 'asc' }],
|
||||||
take: 20,
|
take,
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (endpoints.length === 0) {
|
if (total === 0) {
|
||||||
return { content: [{ type: 'text' as const, text: `No endpoints found matching "${keyword}". Try a different keyword or use list_modules to browse.` }] };
|
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) => ({
|
const results = rows.map((e) => ({
|
||||||
id: e.id, method: e.method, path: e.path, summary: e.summary,
|
id: e.id,
|
||||||
moduleName: e.module.name, deprecated: e.deprecated,
|
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}).` }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user