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(
|
||||
'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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) }] };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}).` }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user