From 4922aaa414b4d934ce85f43315d04327118caed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:35:03 +0000 Subject: [PATCH] Say why a call failed, and stop padding the ones that succeed Four defects found by testing every tool against the live service. An error body was thrown away. Ten catch sites interpolated the caught exception directly, so a caller who passed an unknown query_type got "AxiosError: Request failed with status code 400" while the response body sitting in that exception said "Unknown query_type: NotARealQueryType" and listed all 43 names it would have accepted. rejectionDetail now also handles a timeout (the server keeps computing and caches the result, so retrying is the right advice, and that is the failure most likely to be misread as "no data"), appends the server's `available` and `suggestions` lists, and notes a `status: computing` body. A new failureText() puts every catch site on it. fetchAvailableQueryTypes was typed Promise but returned the raw Queries array, whose entries are objects. formatAvailableQueriesHint then JSON.stringify'd them, so the run_query error path emitted every query's argument schema and empty preview block. It now maps to the query names. get_hierarchy returned the same tree three times: as data, as `display`, as a byte-identical `display_full`, and as an `html` document written for the VFB site's ROI browser. The HTML is now behind include_html (default false) and display_full is dropped when it duplicates display. 4.3 KB -> 1.4 KB on a one-level Kenyon cell tree. get_term_info carried an argument schema and an empty preview block for every entry in Queries, and six file URLs for every image. Trimmed by default with a `trimmed` field naming what went and where the dropped file URLs live, so nothing is unrecoverable; verbose: true returns the untouched response. 15.7 KB -> 7.6 KB on FBbt_00100249. test/live-smoke.js drives the built server over stdio and checks all four against the live service; npm run test:live. Calls are spaced, and every one goes through v3-cached. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Y8ZAhRM7gh8arDx9SuSWcT --- LLM_GUIDANCE.md | 2 + README.md | 17 +++- TECHNICAL.md | 6 +- package-lock.json | 4 +- package.json | 5 +- server.json | 2 +- src/index.ts | 210 ++++++++++++++++++++++++++++++++++++++------- test/live-smoke.js | 105 +++++++++++++++++++++++ 8 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 test/live-smoke.js diff --git a/LLM_GUIDANCE.md b/LLM_GUIDANCE.md index 9975c34..41d9a10 100644 --- a/LLM_GUIDANCE.md +++ b/LLM_GUIDANCE.md @@ -617,6 +617,8 @@ After showing the text tree, offer the user an interactive HTML version they can https://v3-cached.virtualflybrain.org/get_hierarchy_html?id=&relationship=&direction=&max_depth= ``` +The `get_hierarchy` tool itself omits that HTML by default — it is a second copy of the tree you already have in `descendants`/`ancestors`, and it is usually three quarters of the response. Pass `include_html: true` if you actually intend to render it. + For example: `https://v3-cached.virtualflybrain.org/get_hierarchy_html?id=FBbt_00003686&relationship=subclass_of&direction=both&max_depth=2` The HTML page has a collapsible interactive tree with clickable links to VFB for every term. diff --git a/README.md b/README.md index dfe749b..f717a2f 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,10 @@ docker run -p 3000:3000 virtualflybrain/vfb3-mcp:latest Retrieve detailed information about VFB terms using their IDs. **Parameters:** -- `id` (string): VFB ID (e.g., "VFB_jrcv0i43") +- `id` (string or array): One or more VFB IDs (e.g., "VFB_jrcv0i43"); an array is fetched in parallel and returned keyed by ID +- `verbose` (boolean, optional): Return the raw response (default false) + +By default the response is trimmed, which roughly halves it. Each `Queries` entry keeps `query`, `label`, `preview_columns` and `output_format`; its argument schema (`takes`) and its `preview_results` block are dropped when that block has no rows, since it otherwise just repeats `preview_columns`. Each image entry keeps `id`, `label` and `thumbnail`; the other five file URLs are dropped, and the response says where they are — same directory as the thumbnail, named `thumbnailT.png`, `volume.nrrd`, `volume.wlz`, `volume_man.obj`, `volume.swc`. Nothing here is a guess about what you need: pass `verbose: true` and you get the untouched response. ### run_query Execute predefined queries on VFB data. @@ -313,6 +316,18 @@ List the valid `facets_annotation` type names for the four type filters above, r If the deployed VFBquery predates the `/facets` endpoint, this falls back to a snapshot bundled with the server and says so — names absent from a snapshot result may still be valid. +### get_hierarchy +Traverse the ontology hierarchy for a VFB term. + +**Parameters:** +- `id` (string): VFB term ID (e.g., "FBbt_00005801") +- `relationship` (string): `part_of` for region/tissue structure, `subclass_of` for cell-type taxonomy +- `direction` (string, optional): `descendants`, `ancestors`, or `both` (default `both`) +- `max_depth` (number, optional): Levels to expand (default 1; `-1` for the full tree) +- `include_html` (boolean, optional): Include the HTML rendering (default false) + +The endpoint returns the tree three times over: as data in `descendants`/`ancestors`, as a `display` string, and as an `html` document written for the VFB site's ROI browser — with `display_full` usually byte-identical to `display`. By default the `html` is omitted and `display_full` is dropped when it duplicates `display`, which takes a typical response from ~4.3 KB to ~1.4 KB. Pass `include_html: true` if you are embedding the site rendering. + ### query_connectivity Query synaptic connectivity between neuron classes across all connectome datasets. At least one of `upstream_type` or `downstream_type` is required. Results are ranked strongest-first and paged: you get `limit` rows plus a `summary` computed over **every** connection found — weight min/max/total/mean, per-dataset counts, distinct neuron counts, and the top class pairs — so the totals stay true even though the rows are truncated. A broad query can find tens of thousands of connections, which is why paging is on by default. diff --git a/TECHNICAL.md b/TECHNICAL.md index 14d5375..82a4339 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -186,9 +186,9 @@ it: one ranking, one place to fix it. ### MCP Tools Implementation #### get_term_info -- **Input**: VFB ID string -- **Output**: Term metadata, classifications, images, publications -- **API Call**: POST to term info endpoint +- **Input**: VFB ID string or array of IDs; optional `verbose` +- **Output**: Term metadata, classifications, images, publications. Trimmed by default (empty query previews, query argument schemas and the non-thumbnail image file URLs are removed, and a `trimmed` field says what went and how to get it back) — about half the bytes. `verbose: true` returns the response untouched +- **API Call**: GET `get_term_info` #### run_query - **Input**: VFB ID(s) and query type; optional `limit`/`offset` (paging) and `include_images` diff --git a/package-lock.json b/package-lock.json index 8f1e9ac..c8ecbb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vfb3-mcp", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vfb3-mcp", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index a3d052a..567d263 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "vfb3-mcp", - "version": "1.10.0", + "version": "1.11.0", "description": "MCP server for VirtualFlyBrain API integration", "main": "index.js", "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsc && node dist/index.js" + "dev": "tsc && node dist/index.js", + "test:live": "node test/live-smoke.js" }, "repository": { "type": "git", diff --git a/server.json b/server.json index 3ed8b71..db232c4 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "org.virtualflybrain/vfb3-mcp", "title": "VirtualFlyBrain", "description": "MCP server for Drosophila neuroscience data from VirtualFlyBrain", - "version": "1.10.0", + "version": "1.11.0", "websiteUrl": "https://virtualflybrain.org", "repository": { "url": "https://github.com/Robbie1977/VFB3-MCP", diff --git a/src/index.ts b/src/index.ts index f388771..f07427d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,6 +133,11 @@ function setupToolHandlers(server: Server, sessionIdHolder?: RequestContext) { ], description: 'One or more VFB IDs to look up', }, + verbose: { + type: 'boolean', + description: 'Return the raw response. By default the Queries entries are trimmed to query/label/preview_columns/output_format (dropping argument schemas and empty preview blocks) and image entries to id/label/thumbnail, which roughly halves the payload without losing anything you need to call run_query. Set true if you need the argument schemas or the nrrd/wlz/obj/swc file URLs.', + default: false, + }, }, required: ['id'], }, @@ -356,6 +361,11 @@ function setupToolHandlers(server: Server, sessionIdHolder?: RequestContext) { description: 'Number of levels to expand. 1 = direct children/parents only. Higher values go deeper. -1 = full tree (use with caution on broad terms). Default: 1.', default: 1, }, + include_html: { + type: 'boolean', + description: 'Include the HTML rendering of the tree. Off by default: it is the VFB site\'s ROI-browser markup for the same tree already returned in descendants/ancestors, and it is typically three quarters of the response. Set true only if you are embedding the site rendering.', + default: false, + }, }, required: ['id', 'relationship'], }, @@ -379,7 +389,7 @@ function setupToolHandlers(server: Server, sessionIdHolder?: RequestContext) { try { switch (name) { case 'get_term_info': - return await handleGetTermInfo(args as { id: string | string[] }); + return await handleGetTermInfo(args as { id: string | string[]; verbose?: boolean }); case 'run_query': return await handleRunQuery(args as { id?: string | string[]; query_type?: string; queries?: Array<{ id: string; query_type: string }>; limit?: number; offset?: number; include_images?: boolean }); case 'search_terms': @@ -395,7 +405,7 @@ function setupToolHandlers(server: Server, sessionIdHolder?: RequestContext) { case 'query_connectivity': return await handleQueryConnectivity(args as { upstream_type?: string; downstream_type?: string; weight?: number; group_by_class?: boolean; exclude_dbs?: string[]; limit?: number; offset?: number }); case 'get_hierarchy': - return await handleGetHierarchy(args as { id: string; relationship: string; direction?: string; max_depth?: number }); + return await handleGetHierarchy(args as { id: string; relationship: string; direction?: string; max_depth?: number; include_html?: boolean }); default: console.error('MCP Debug: Unknown tool requested:', name); throw new McpError( @@ -407,7 +417,7 @@ function setupToolHandlers(server: Server, sessionIdHolder?: RequestContext) { console.error('MCP Debug: Error calling tool', name, ':', error); throw new McpError( ErrorCode.InternalError, - `Error calling tool ${name}: ${error}` + failureText(`Error calling tool ${name}`, error) ); } }); @@ -426,12 +436,114 @@ async function fetchSingleTermInfo(id: string): Promise<{ data?: any; error?: st return { data: response.data }; } catch (error) { console.error(`MCP Debug: Error fetching term info for id=${id}:`, error); - return { error: `Error fetching term info for "${id}": ${error}` }; + return { error: failureText(`Error fetching term info for "${id}"`, error) }; } } -async function handleGetTermInfo(args: { id: string | string[] }) { +// --------------------------------------------------------------------------- +// Payload shaping +// +// Both get_term_info and get_hierarchy answer with responses built for the VFB +// website rather than for a model: term info repeats every query's column +// headers twice around an empty preview, and lists six file URLs per image that +// differ only in filename; a hierarchy carries an HTML rendering of the same +// tree it already returned as data, plus a `display_full` string that is +// usually byte-identical to `display`. That scaffolding crowds out the answer. +// Both shapers are lossless in the sense that matters: the omitted parts are +// either reconstructible from what remains or one flag away (`verbose` / +// `include_html`). +// --------------------------------------------------------------------------- + +/** Per-image file URLs that all sit beside the thumbnail and are named below. */ +const IMAGE_FILE_URL_KEYS = ['thumbnail_transparent', 'nrrd', 'wlz', 'obj', 'swc']; + +const IMAGE_TRIM_NOTE = 'Image entries keep id/label/thumbnail. The other files sit in the ' + + 'same directory as the thumbnail: thumbnailT.png (transparent), volume.nrrd, volume.wlz, ' + + 'volume_man.obj, volume.swc. Pass verbose=true for the untrimmed response.'; + +const QUERY_TRIM_NOTE = 'Queries entries keep query/label/preview_columns/output_format. The ' + + 'argument schema and the empty preview block are omitted; the query name is all run_query ' + + 'needs. Pass verbose=true for the untrimmed response.'; + +function trimImageEntry(entry: any): any { + if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const out: any = {}; + for (const [key, value] of Object.entries(entry)) { + if (IMAGE_FILE_URL_KEYS.includes(key)) continue; + out[key] = value; + } + return out; +} + +function trimQueryEntry(query: any): any { + if (query == null || typeof query !== 'object' || Array.isArray(query)) return query; + const out: any = {}; + for (const key of ['query', 'label', 'preview_columns', 'output_format']) { + if (query[key] !== undefined) out[key] = query[key]; + } + // count is -1 when the server did not compute one; a real total is worth keeping. + if (typeof query.count === 'number' && query.count >= 0) out.count = query.count; + // A preview with rows in it is real data. An empty one is just the headers again. + const rows = query?.preview_results?.rows; + if (Array.isArray(rows) && rows.length > 0) out.preview_results = query.preview_results; + return out; +} + +function shapeTermInfo(data: any, verbose: boolean): any { + if (verbose) return data; + if (data == null || typeof data !== 'object' || Array.isArray(data)) return data; + const out: any = { ...data }; + const notes: string[] = []; + + if (Array.isArray(out.Queries) && out.Queries.length > 0) { + out.Queries = out.Queries.map(trimQueryEntry); + notes.push(QUERY_TRIM_NOTE); + } + + let trimmedAnyImage = false; + for (const key of ['Images', 'Examples']) { + const group = out[key]; + if (!group || typeof group !== 'object' || Array.isArray(group)) continue; + const shaped: Record = {}; + for (const [templateId, list] of Object.entries(group)) { + if (Array.isArray(list)) { + shaped[templateId] = list.map(trimImageEntry); + if (list.length > 0) trimmedAnyImage = true; + } else { + shaped[templateId] = list; + } + } + out[key] = shaped; + } + if (trimmedAnyImage) notes.push(IMAGE_TRIM_NOTE); + + if (notes.length > 0) out.trimmed = notes.join(' '); + return out; +} + +function shapeHierarchy(data: any, includeHtml: boolean): any { + if (data == null || typeof data !== 'object' || Array.isArray(data)) return data; + const out: any = { ...data }; + const notes: string[] = []; + + // `html` is the VFB site's ROI-browser rendering of the tree already present + // in `descendants`/`ancestors` -- typically three quarters of the response. + if (!includeHtml && typeof out.html === 'string') { + delete out.html; + notes.push('An HTML rendering of this same tree was omitted; pass include_html=true for it.'); + } + // `display_full` is only interesting when it differs from `display`. + if (typeof out.display_full === 'string' && out.display_full === out.display) { + delete out.display_full; + notes.push('display_full was byte-identical to display and was dropped.'); + } + if (notes.length > 0) out.trimmed = notes.join(' '); + return out; +} + +async function handleGetTermInfo(args: { id: string | string[]; verbose?: boolean }) { const { id } = args; + const verbose = args.verbose === true; // Single ID — preserve original response format if (typeof id === 'string') { @@ -440,7 +552,7 @@ async function handleGetTermInfo(args: { id: string | string[] }) { content: [ { type: 'text', - text: result.error || JSON.stringify(result.data, null, 2), + text: result.error || JSON.stringify(shapeTermInfo(result.data, verbose), null, 2), }, ], }; @@ -455,7 +567,7 @@ async function handleGetTermInfo(args: { id: string | string[] }) { const keyed: Record = {}; for (const r of results) { - keyed[r.id] = r.error ? { error: r.error } : r.data; + keyed[r.id] = r.error ? { error: r.error } : shapeTermInfo(r.data, verbose); } return { @@ -480,8 +592,12 @@ async function fetchAvailableQueryTypes(id: string): Promise { const result = await fetchSingleTermInfo(id); if (!result.data) return null; const queries = result.data.Queries; - if (Array.isArray(queries)) return queries; - return null; + if (!Array.isArray(queries)) return null; + // Each entry is an object ({query, label, takes, preview, ...}), not a name. + // Returning it raw made the error path JSON.stringify the whole schema. + return queries + .map((q: any) => (typeof q === 'string' ? q : q?.query)) + .filter((q: any): q is string => typeof q === 'string' && q.length > 0); } catch { return null; } @@ -489,7 +605,7 @@ async function fetchAvailableQueryTypes(id: string): Promise { function formatAvailableQueriesHint(id: string, queries: string[] | null): string { if (queries && queries.length > 0) { - return `\n\nAvailable query_types for "${id}" (from get_term_info Queries array): ${JSON.stringify(queries)}\nPick one of these for run_query, or call get_term_info("${id}") for full details.`; + return `\n\nAvailable query_types for "${id}" (from get_term_info Queries array): ${queries.join(', ')}\nPick one of these for run_query, or call get_term_info("${id}") for full details.`; } if (queries && queries.length === 0) { return `\n\nget_term_info("${id}") reports no available queries for this entity. The ID may be deprecated, the entity may not support pre-computed queries, or you may need to query a related entity (e.g. its parent class via get_hierarchy).`; @@ -571,7 +687,7 @@ async function fetchSingleQuery(id: string, query_type: string, opts: { limit?: return { data: shapeRunQueryResult(response.data, { includeImages, limit, offset }) }; } catch (error) { console.error(`MCP Debug: Error running query id=${id} query_type=${query_type}:`, error); - return { error: `Error running query "${query_type}" on "${id}": ${error}` }; + return { error: failureText(`Error running query "${query_type}" on "${id}"`, error) }; } } @@ -689,10 +805,50 @@ function dedupeSearchRows(rows: any[]): any[] { // AxiosError up says only "status code 400", which tells the caller nothing about // what to change. function rejectionDetail(error: any): string | null { + // A timeout is not a rejection and has no body, but it is the failure a caller + // is most likely to misread as "no data". The server keeps computing after we + // give up and caches the result, so retrying is the right advice. + const code = error?.code; + if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { + return 'the request took longer than this client will wait. VFBquery is still ' + + 'computing it and will cache the result, so retrying the same call shortly ' + + 'will usually return it quickly. This does NOT mean there is no data.'; + } + const data = error?.response?.data; if (data == null) return null; if (typeof data === 'string') return data.trim() || null; - return data.error ?? data.detail ?? null; + + const base = data.error ?? data.detail ?? null; + if (base == null) return null; + + // VFBquery answers some rejections with the valid alternatives -- an unknown + // query_type comes back with every name it would have accepted. Dropping that + // forces the caller to guess again. + // The server's own sentence rarely ends in punctuation, and running it + // straight into "Valid values:" reads as one broken sentence. + const head = String(base).trim(); + const parts = [/[.!?]$/.test(head) ? head : `${head}.`]; + if (Array.isArray(data.available) && data.available.length) { + parts.push(`Valid values: ${data.available.join(', ')}.`); + } + if (Array.isArray(data.suggestions) && data.suggestions.length) { + parts.push(`Did you mean: ${data.suggestions.join(', ')}?`); + } + if (data.status === 'computing') { + parts.push('The result is still being computed and will be cached — retry shortly.'); + } + return parts.join(' '); +} + +/** Uniform "we could not do it, and here is exactly why" text for any failure. */ +function failureText(context: string, error: any): string { + const detail = rejectionDetail(error); + const status = error?.response?.status; + if (detail) { + return status ? `${context}: ${detail} (HTTP ${status})` : `${context}: ${detail}`; + } + return `${context}: ${error}`; } async function fetchSearch(params: URLSearchParams): Promise { @@ -758,11 +914,7 @@ async function handleSearchTerms(args: { } catch (error: any) { // A 400 from /search carries the reason and, for a misspelled type name, // the suggestions — far more useful to pass through than the status code. - const detail = error?.response?.data?.error; - if (detail) { - return { content: [{ type: 'text', text: `Search rejected: ${detail}` }] }; - } - return { content: [{ type: 'text', text: `Error searching terms: ${error}` }] }; + return { content: [{ type: 'text', text: failureText('Search rejected', error) }] }; } const allRows: any[] = Array.isArray(data?.rows) ? data.rows : []; @@ -925,7 +1077,7 @@ async function handleListSearchFacets(args: { contains?: string }): Promise<{ co }], }; } - return { content: [{ type: 'text', text: `Error listing search facets: ${error}` }] }; + return { content: [{ type: 'text', text: failureText('Error listing search facets', error) }] }; } } @@ -972,7 +1124,7 @@ async function handleResolveEntity(args: { name: string }): Promise<{ content: A const response = await axios.get(url); return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; } catch (error) { - return { content: [{ type: 'text', text: `Error resolving entity "${rawName}": ${error}` }] }; + return { content: [{ type: 'text', text: failureText(`Error resolving entity "${rawName}"`, error) }] }; } } @@ -1004,7 +1156,7 @@ async function handleResolveCombination(args: { name: string }): Promise<{ conte const response = await axios.get(url); return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; } catch (error) { - return { content: [{ type: 'text', text: `Error resolving combination "${rawName}": ${error}` }] }; + return { content: [{ type: 'text', text: failureText(`Error resolving combination "${rawName}"`, error) }] }; } } @@ -1016,7 +1168,7 @@ async function handleListConnectomeDatasets(): Promise<{ content: Array<{ type: const response = await axios.get(url); return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; } catch (error) { - return { content: [{ type: 'text', text: `Error listing connectome datasets: ${error}` }] }; + return { content: [{ type: 'text', text: failureText('Error listing connectome datasets', error) }] }; } } @@ -1189,11 +1341,7 @@ async function handleQueryConnectivity(args: { const response = await axios.get(url, { timeout: 300000 }); // 5 min — live cross-dataset query return { content: [{ type: 'text', text: JSON.stringify(shapeConnectivityResult(response.data, { limit, offset }), null, 2) }] }; } catch (error) { - const detail = rejectionDetail(error); - if (detail) { - return { content: [{ type: 'text', text: `Connectivity query rejected: ${detail}` }] }; - } - return { content: [{ type: 'text', text: `Error querying connectivity: ${error}` }] }; + return { content: [{ type: 'text', text: failureText('Connectivity query rejected', error) }] }; } } @@ -1202,6 +1350,7 @@ async function handleGetHierarchy(args: { relationship: string; direction?: string; max_depth?: number; + include_html?: boolean; }): Promise<{ content: Array<{ type: string; text: string }> }> { const params = new URLSearchParams(); params.set('id', args.id); @@ -1215,13 +1364,10 @@ async function handleGetHierarchy(args: { console.error(`MCP Debug: get_hierarchy params=${params.toString()}`); try { const response = await axios.get(url, { timeout: 120000 }); // 2 min - return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; + const shaped = shapeHierarchy(response.data, args.include_html === true); + return { content: [{ type: 'text', text: JSON.stringify(shaped, null, 2) }] }; } catch (error) { - const detail = rejectionDetail(error); - if (detail) { - return { content: [{ type: 'text', text: `Hierarchy request rejected: ${detail}` }] }; - } - return { content: [{ type: 'text', text: `Error getting hierarchy: ${error}` }] }; + return { content: [{ type: 'text', text: failureText('Hierarchy request rejected', error) }] }; } } diff --git a/test/live-smoke.js b/test/live-smoke.js new file mode 100644 index 0000000..186a4fe --- /dev/null +++ b/test/live-smoke.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Drives the built server over stdio and exercises the four fixes: + * A/B a rejected run_query must surface the server's reason and the valid + * query_type names, not "AxiosError: ... status code 400" + * C the available-query hint must be a comma list of names, not a blob of + * JSON-stringified query objects + * D get_hierarchy must drop `html` and the duplicate `display_full` + * E get_term_info must trim Queries and image entries, and `verbose: true` + * must give the untouched response back + * + * One call per check, run in sequence with a pause between, per the standing + * rule about load on VFBquery. Every call goes to v3-cached. + */ +const { spawn } = require('child_process'); + +const child = spawn('node', [__dirname + '/../dist/index.js'], { + stdio: ['pipe', 'pipe', 'inherit'], +}); + +let buffer = ''; +const pending = new Map(); +child.stdout.on('data', (chunk) => { + buffer += chunk.toString(); + let nl; + while ((nl = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + const resolve = pending.get(msg.id); + if (resolve) { pending.delete(msg.id); resolve(msg); } + } +}); + +let nextId = 1; +function send(method, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + setTimeout(() => { if (pending.delete(id)) reject(new Error(`timeout on ${method}`)); }, 120000); + }); +} +const call = (name, args) => send('tools/call', { name, arguments: args }) + .then((r) => r.result?.content?.[0]?.text ?? JSON.stringify(r)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failures = 0; +function check(label, ok, detail) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`); + if (!ok) { failures++; if (detail) console.log(' ' + String(detail).slice(0, 400)); } +} + +(async () => { + await send('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'fix-test', version: '0' }, + }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + + // --- A/B + C: rejected run_query ----------------------------------------- + const bad = await call('run_query', { id: 'FBbt_00003686', query_type: 'NotARealQueryType' }); + check('run_query surfaces the server reason', /Unknown query_type/i.test(bad), bad); + check('run_query lists the valid values', /SimilarMorphologyTo/.test(bad), bad); + check('run_query no longer leaks a bare AxiosError', !/AxiosError/.test(bad), bad); + check('available-query hint is names, not objects', !/"preview_results"|"output_format"/.test(bad), bad); + console.log(` [${bad.length} chars]`); + await sleep(8000); + + // --- D: hierarchy --------------------------------------------------------- + const hier = await call('get_hierarchy', { id: 'FBbt_00003686', relationship: 'subclass_of', max_depth: 1 }); + check('get_hierarchy drops the html blob', !/"html"/.test(hier), hier.slice(0, 200)); + check('get_hierarchy drops duplicate display_full', !/"display_full"/.test(hier), hier.slice(0, 200)); + check('get_hierarchy keeps the tree', /"descendants"/.test(hier), hier.slice(0, 200)); + console.log(` [${hier.length} chars]`); + await sleep(8000); + + const hierHtml = await call('get_hierarchy', { id: 'FBbt_00003686', relationship: 'subclass_of', max_depth: 1, include_html: true }); + check('include_html=true returns the html', /"html"/.test(hierHtml), hierHtml.slice(0, 200)); + console.log(` [${hierHtml.length} chars]`); + await sleep(8000); + + // --- E: term info --------------------------------------------------------- + const slim = await call('get_term_info', { id: 'FBbt_00100249' }); + check('get_term_info drops empty preview blocks', !/"preview_results"/.test(slim), slim.slice(0, 200)); + check('get_term_info drops the argument schema', !/"takes"/.test(slim), slim.slice(0, 200)); + // Match a real URL, not the trim note, which names the omitted filenames. + check('get_term_info drops the extra file URLs', !/https:\S+volume\.nrrd/.test(slim), + (slim.match(/.{60}volume\.nrrd/) || [''])[0]); + check('get_term_info keeps query names', /ListAllAvailableImages/.test(slim), slim.slice(0, 200)); + check('get_term_info keeps thumbnails', /thumbnail\.png/.test(slim), slim.slice(0, 200)); + await sleep(8000); + + const full = await call('get_term_info', { id: 'FBbt_00100249', verbose: true }); + check('verbose=true restores the raw response', /"takes"/.test(full) && /volume\.nrrd/.test(full), full.slice(0, 200)); + console.log(` get_term_info: ${slim.length} chars slim vs ${full.length} verbose ` + + `(${Math.round(100 - (100 * slim.length) / full.length)}% smaller)`); + + console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`); + child.kill(); + process.exit(failures === 0 ? 0 : 1); +})().catch((err) => { console.error(err); child.kill(); process.exit(1); });