diff --git a/packages/mcp/src/lib/responses.ts b/packages/mcp/src/lib/responses.ts new file mode 100644 index 0000000..0aab481 --- /dev/null +++ b/packages/mcp/src/lib/responses.ts @@ -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 }; +} diff --git a/packages/mcp/src/lib/schema-flatten.ts b/packages/mcp/src/lib/schema-flatten.ts new file mode 100644 index 0000000..304fef1 --- /dev/null +++ b/packages/mcp/src/lib/schema-flatten.ts @@ -0,0 +1,221 @@ +type AnySchema = Record | 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 { + const merged: Record = { 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(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 = { kind: 'object', fields }; + if (schema.additionalProperties && Object.keys(schema.properties || {}).length === 0) { + out.additionalProperties = true; + } + return out; + } + + const primitive: Extract = { + 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; + 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; + // 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).map(([status, raw]) => { + const r = raw as Record; + 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; + }); +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 170eb35..74bf39c 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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: [] } | { kind: "array", items: } | { kind: "primitive", type, ... } | { kind: "variants", variants: [] }. 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; diff --git a/packages/mcp/src/tools/get-endpoint-detail.ts b/packages/mcp/src/tools/get-endpoint-detail.ts index bd06370..96ccf6d 100644 --- a/packages/mcp/src/tools/get-endpoint-detail.ts +++ b/packages/mcp/src/tools/get-endpoint-detail.ts @@ -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), + }); } diff --git a/packages/mcp/src/tools/get-project-overview.ts b/packages/mcp/src/tools/get-project-overview.ts index d266949..474795d 100644 --- a/packages/mcp/src/tools/get-project-overview.ts +++ b/packages/mcp/src/tools/get-project-overview.ts @@ -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) }] }; + }); } diff --git a/packages/mcp/src/tools/list-endpoints.ts b/packages/mcp/src/tools/list-endpoints.ts index 1c6b053..bd5155c 100644 --- a/packages/mcp/src/tools/list-endpoints.ts +++ b/packages/mcp/src/tools/list-endpoints.ts @@ -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, + }); } diff --git a/packages/mcp/src/tools/list-modules.ts b/packages/mcp/src/tools/list-modules.ts index f200d7e..40508e6 100644 --- a/packages/mcp/src/tools/list-modules.ts +++ b/packages/mcp/src/tools/list-modules.ts @@ -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, + })), + }); } diff --git a/packages/mcp/src/tools/search-endpoints.ts b/packages/mcp/src/tools/search-endpoints.ts index 1b9b453..c2f9e3a 100644 --- a/packages/mcp/src/tools/search-endpoints.ts +++ b/packages/mcp/src/tools/search-endpoints.ts @@ -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}).` }), + }); }