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
JavaScript
/**
* @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