UNPKG

clinicaltrialsgov-mcp-server

Version:

Search ClinicalTrials.gov trials, retrieve study details and results, and match patients to eligible trials via MCP. STDIO or Streamable HTTP.

254 lines 12.5 kB
/** * @fileoverview Discover valid field names from the ClinicalTrials.gov data model. * Supports keyword search, path-based drill-down, and top-level overview via an explicit mode field. * @module mcp-server/tools/definitions/get-field-definitions.tool */ import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode, validationError } from '@cyanheads/mcp-ts-core/errors'; import { RECOVERY_HINTS } from '../../../mcp-server/tools/utils/recovery-hints.js'; import { getClinicalTrialsService } from '../../../services/clinical-trials/clinical-trials-service.js'; /** Build a result object, omitting undefined optional fields (exactOptionalPropertyTypes). */ function toFieldResult(node, path) { const r = { name: node.name, path }; if (node.piece != null) r.piece = node.piece; if (node.sourceType != null) r.sourceType = node.sourceType; if (node.type != null) r.type = node.type; if (node.isEnum != null) r.isEnum = node.isEnum; if (node.description != null) r.description = node.description; return r; } /** Convert a flat search-index entry into the tool's output shape. */ function indexEntryToResult(entry) { const r = { name: entry.name, path: entry.path, piece: entry.piece }; if (entry.sourceType != null) r.sourceType = entry.sourceType; if (entry.type != null) r.type = entry.type; if (entry.isEnum != null) r.isEnum = entry.isEnum; if (entry.description != null) r.description = entry.description; return r; } export const getFieldDefinitions = tool('clinicaltrials_get_field_definitions', { description: 'Resolve valid field names from the ClinicalTrials.gov data model — the canonical PascalCase identifiers (OverallStatus, EnrollmentCount, LeadSponsorName) accepted by the `fields`, `advancedFilter`, and `sort` parameters of other tools, and as input to clinicaltrials_get_field_values. Select a mode: `"search"` — keyword search returning ranked matches (pass `query`, e.g. "enrollment", "sponsor", "adverse events"); `"drill"` — drill into a specific section by dot-notation path (pass `path`, e.g. "protocolSection.designModule"); `"overview"` — top-level summary of all sections (no additional args).', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true, }, errors: [ { reason: 'path_not_found', code: JsonRpcErrorCode.NotFound, when: 'The dot-notation path does not match any node in the field tree.', recovery: RECOVERY_HINTS.path_not_found, }, { reason: 'rate_limited', code: JsonRpcErrorCode.RateLimited, when: 'ClinicalTrials.gov returned 429 after retry budget exhausted.', recovery: RECOVERY_HINTS.rate_limited, retryable: true, }, ], input: z.object({ mode: z .enum(['search', 'drill', 'overview']) .describe('Operation mode. "search" — keyword search (requires `query`); "drill" — drill into a section by path (requires `path`); "overview" — list all top-level sections (no other args needed).'), query: z .string() .optional() .describe('search mode only. Keyword to search field names by — e.g., "enrollment", "sponsor", "adverse events". Returns matching field names ranked by relevance with their full paths and data types.'), path: z .string() .optional() .describe('drill mode only. Dot-notation path to drill into — e.g., "protocolSection.designModule", "protocolSection.eligibilityModule", "resultsSection". Returns the section\'s individual fields.'), limit: z .number() .int() .min(1) .max(100) .default(20) .describe('search mode only. Maximum results to return. Default: 20.'), includeIndexedOnly: z .boolean() .optional() .describe('drill mode only. Only return indexed (searchable) fields. Default: false.'), }), output: z.object({ fields: z .array(z .object({ name: z.string().describe('Field name (camelCase).'), piece: z .string() .optional() .describe('PascalCase identifier for use in `fields`/`AREA[]`/`sort` params.'), sourceType: z.string().optional().describe('Data type in the model.'), type: z.string().optional().describe('Semantic type.'), isEnum: z.boolean().optional().describe('Whether the field is an enum type.'), description: z .string() .optional() .describe('Human-readable description from the upstream data model. Often absent.'), path: z.string().optional().describe('Full dot-notation path.'), children: z .array(z.record(z.string(), z.unknown())) .optional() .describe('Child fields (overview mode only).'), }) .describe('A single field definition node.')) .describe('Field definitions, ordered by relevance when mode is "search".'), totalFields: z.number().describe('Total fields returned.'), resolvedPath: z.string().optional().describe('Resolved path when mode is "drill".'), }), // Agent-facing context — query echo, truncation disclosure, and no-match guidance for search mode. enrichment: { searchQuery: z .string() .optional() .describe('Echo of the keyword used in search mode. Absent for drill and overview.'), truncated: z .boolean() .optional() .describe('True when the field list was capped by the limit parameter (search mode only).'), shown: z.number().optional().describe('Number of fields returned (search mode only).'), cap: z.number().optional().describe('The limit cap applied to this search (search mode only).'), notice: z .string() .optional() .describe('Recovery guidance when search mode returns no matches, or a truncation note when results are capped.'), }, async handler(input, ctx) { const service = getClinicalTrialsService(); switch (input.mode) { case 'search': { if (!input.query) { throw validationError('mode="search" requires `query`. Pass a keyword to search by.'); } const { entries: matches, total } = await service.searchFieldDefinitions(input.query, input.limit, ctx); const fields = matches.map(indexEntryToResult); ctx.log.info('Field search completed', { query: input.query, matchCount: fields.length, total, }); ctx.enrich({ searchQuery: input.query }); // Disclose truncation only when the match set actually exceeded the cap — // otherwise the "raise the cap" notice misleads when shown < cap. if (total > input.limit) { ctx.enrich.truncated({ shown: fields.length, cap: input.limit }); } if (fields.length === 0) { ctx.enrich.notice(`No fields matched "${input.query}". Try a broader keyword (e.g. "enrollment", "sponsor", "eligibility") or use mode="overview" to see all top-level sections.`); } return { fields, totalFields: fields.length }; } case 'drill': { if (!input.path) { throw validationError('mode="drill" requires `path`. Pass a dot-notation path such as "protocolSection.designModule".'); } const tree = await service.getMetadata(input.includeIndexedOnly ?? false, ctx); const node = navigateToPath(tree, input.path); if (!node) { throw ctx.fail('path_not_found', `Path '${input.path}' not found. Top-level sections: ${tree.map((n) => n.name).join(', ')}.`, { ...ctx.recoveryFor('path_not_found') }); } const fields = flattenChildren(node, input.path); ctx.log.info('Field path resolved', { path: input.path, fieldCount: fields.length }); return { fields, totalFields: fields.length, resolvedPath: input.path }; } case 'overview': { const tree = await service.getMetadata(false, ctx); const overview = tree.map((section) => { const r = toFieldResult(section, section.name); if (section.children) { r.children = section.children.map((child) => ({ name: child.name, ...(child.piece != null && { piece: child.piece }), ...(child.type != null && { type: child.type }), ...(child.isEnum != null && { isEnum: child.isEnum }), hasChildren: (child.children?.length ?? 0) > 0, })); } return r; }); const total = overview.reduce((n, s) => n + 1 + (Array.isArray(s.children) ? s.children.length : 0), 0); ctx.log.info('Field overview returned', { sections: overview.length, totalFields: total }); return { fields: overview, totalFields: total }; } } }, format: (result) => { const lines = []; if (result.resolvedPath) { lines.push(`**${result.resolvedPath}** (${result.totalFields} fields):\n`); } for (const field of result.fields) { const piece = field.piece ? ` [${field.piece}]` : ''; const typeParts = [field.sourceType, field.type].filter(Boolean); if (field.isEnum) typeParts.push('ENUM'); const typeStr = typeParts.length ? ` (${typeParts.join(', ')})` : ''; const path = field.path ? ` — ${field.path}` : ''; if (field.children && Array.isArray(field.children)) { lines.push(`${field.name}${piece}${typeStr}${path}`); if (field.description) lines.push(` ${field.description}`); lines.push(` children (${field.children.length}):`); for (const child of field.children) { const cp = child.piece ? ` [${child.piece}]` : ''; const ct = child.type ?? ''; const ce = child.isEnum ? ', ENUM' : ''; const cts = ct || ce ? ` (${ct}${ce})` : ''; const arrow = child.hasChildren ? ' →' : ''; lines.push(` ${child.name}${cp}${cts}${arrow}`); } } else { lines.push(`${field.name}${piece}${typeStr}${path}`); if (field.description) lines.push(` ${field.description}`); } } if (lines.length === 0) lines.push('No fields found.'); return [{ type: 'text', text: lines.join('\n') }]; }, }); /** Navigate the field tree to a dot-notation path. */ function navigateToPath(nodes, path) { const segments = path.split('.'); let current = nodes; for (let i = 0; i < segments.length; i++) { const match = current.find((n) => n.name === segments[i]); if (!match) return null; if (i === segments.length - 1) return match; if (!match.children) return null; current = match.children; } return null; } /** Flatten a node's children into a field list. */ function flattenChildren(node, basePath) { const results = []; if (node.children) { for (const child of node.children) { const childPath = `${basePath}.${child.name}`; results.push(toFieldResult(child, childPath)); if (child.children) { results.push(...flattenChildren(child, childPath)); } } } return results; } //# sourceMappingURL=get-field-definitions.tool.js.map