This is page 16 of 20. Use http://codebase.md/genomoncology/biomcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ ├── actions
│ │ └── setup-python-env
│ │ └── action.yml
│ ├── dependabot.yml
│ └── workflows
│ ├── ci.yml
│ ├── deploy-docs.yml
│ ├── main.yml.disabled
│ ├── on-release-main.yml
│ └── validate-codecov-config.yml
├── .gitignore
├── .pre-commit-config.yaml
├── BIOMCP_DATA_FLOW.md
├── CHANGELOG.md
├── CNAME
├── codecov.yaml
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── apis
│ │ ├── error-codes.md
│ │ ├── overview.md
│ │ └── python-sdk.md
│ ├── assets
│ │ ├── biomcp-cursor-locations.png
│ │ ├── favicon.ico
│ │ ├── icon.png
│ │ ├── logo.png
│ │ ├── mcp_architecture.txt
│ │ └── remote-connection
│ │ ├── 00_connectors.png
│ │ ├── 01_add_custom_connector.png
│ │ ├── 02_connector_enabled.png
│ │ ├── 03_connect_to_biomcp.png
│ │ ├── 04_select_google_oauth.png
│ │ └── 05_success_connect.png
│ ├── backend-services-reference
│ │ ├── 01-overview.md
│ │ ├── 02-biothings-suite.md
│ │ ├── 03-cbioportal.md
│ │ ├── 04-clinicaltrials-gov.md
│ │ ├── 05-nci-cts-api.md
│ │ ├── 06-pubtator3.md
│ │ └── 07-alphagenome.md
│ ├── blog
│ │ ├── ai-assisted-clinical-trial-search-analysis.md
│ │ ├── images
│ │ │ ├── deep-researcher-video.png
│ │ │ ├── researcher-announce.png
│ │ │ ├── researcher-drop-down.png
│ │ │ ├── researcher-prompt.png
│ │ │ ├── trial-search-assistant.png
│ │ │ └── what_is_biomcp_thumbnail.png
│ │ └── researcher-persona-resource.md
│ ├── changelog.md
│ ├── CNAME
│ ├── concepts
│ │ ├── 01-what-is-biomcp.md
│ │ ├── 02-the-deep-researcher-persona.md
│ │ └── 03-sequential-thinking-with-the-think-tool.md
│ ├── developer-guides
│ │ ├── 01-server-deployment.md
│ │ ├── 02-contributing-and-testing.md
│ │ ├── 03-third-party-endpoints.md
│ │ ├── 04-transport-protocol.md
│ │ ├── 05-error-handling.md
│ │ ├── 06-http-client-and-caching.md
│ │ ├── 07-performance-optimizations.md
│ │ └── generate_endpoints.py
│ ├── faq-condensed.md
│ ├── FDA_SECURITY.md
│ ├── genomoncology.md
│ ├── getting-started
│ │ ├── 01-quickstart-cli.md
│ │ ├── 02-claude-desktop-integration.md
│ │ └── 03-authentication-and-api-keys.md
│ ├── how-to-guides
│ │ ├── 01-find-articles-and-cbioportal-data.md
│ │ ├── 02-find-trials-with-nci-and-biothings.md
│ │ ├── 03-get-comprehensive-variant-annotations.md
│ │ ├── 04-predict-variant-effects-with-alphagenome.md
│ │ ├── 05-logging-and-monitoring-with-bigquery.md
│ │ └── 06-search-nci-organizations-and-interventions.md
│ ├── index.md
│ ├── policies.md
│ ├── reference
│ │ ├── architecture-diagrams.md
│ │ ├── quick-architecture.md
│ │ ├── quick-reference.md
│ │ └── visual-architecture.md
│ ├── robots.txt
│ ├── stylesheets
│ │ ├── announcement.css
│ │ └── extra.css
│ ├── troubleshooting.md
│ ├── tutorials
│ │ ├── biothings-prompts.md
│ │ ├── claude-code-biomcp-alphagenome.md
│ │ ├── nci-prompts.md
│ │ ├── openfda-integration.md
│ │ ├── openfda-prompts.md
│ │ ├── pydantic-ai-integration.md
│ │ └── remote-connection.md
│ ├── user-guides
│ │ ├── 01-command-line-interface.md
│ │ ├── 02-mcp-tools-reference.md
│ │ └── 03-integrating-with-ides-and-clients.md
│ └── workflows
│ └── all-workflows.md
├── example_scripts
│ ├── mcp_integration.py
│ └── python_sdk.py
├── glama.json
├── LICENSE
├── lzyank.toml
├── Makefile
├── mkdocs.yml
├── package-lock.json
├── package.json
├── pyproject.toml
├── README.md
├── scripts
│ ├── check_docs_in_mkdocs.py
│ ├── check_http_imports.py
│ └── generate_endpoints_doc.py
├── smithery.yaml
├── src
│ └── biomcp
│ ├── __init__.py
│ ├── __main__.py
│ ├── articles
│ │ ├── __init__.py
│ │ ├── autocomplete.py
│ │ ├── fetch.py
│ │ ├── preprints.py
│ │ ├── search_optimized.py
│ │ ├── search.py
│ │ └── unified.py
│ ├── biomarkers
│ │ ├── __init__.py
│ │ └── search.py
│ ├── cbioportal_helper.py
│ ├── circuit_breaker.py
│ ├── cli
│ │ ├── __init__.py
│ │ ├── articles.py
│ │ ├── biomarkers.py
│ │ ├── diseases.py
│ │ ├── health.py
│ │ ├── interventions.py
│ │ ├── main.py
│ │ ├── openfda.py
│ │ ├── organizations.py
│ │ ├── server.py
│ │ ├── trials.py
│ │ └── variants.py
│ ├── connection_pool.py
│ ├── constants.py
│ ├── core.py
│ ├── diseases
│ │ ├── __init__.py
│ │ ├── getter.py
│ │ └── search.py
│ ├── domain_handlers.py
│ ├── drugs
│ │ ├── __init__.py
│ │ └── getter.py
│ ├── exceptions.py
│ ├── genes
│ │ ├── __init__.py
│ │ └── getter.py
│ ├── http_client_simple.py
│ ├── http_client.py
│ ├── individual_tools.py
│ ├── integrations
│ │ ├── __init__.py
│ │ ├── biothings_client.py
│ │ └── cts_api.py
│ ├── interventions
│ │ ├── __init__.py
│ │ ├── getter.py
│ │ └── search.py
│ ├── logging_filter.py
│ ├── metrics_handler.py
│ ├── metrics.py
│ ├── oncokb_helper.py
│ ├── openfda
│ │ ├── __init__.py
│ │ ├── adverse_events_helpers.py
│ │ ├── adverse_events.py
│ │ ├── cache.py
│ │ ├── constants.py
│ │ ├── device_events_helpers.py
│ │ ├── device_events.py
│ │ ├── drug_approvals.py
│ │ ├── drug_labels_helpers.py
│ │ ├── drug_labels.py
│ │ ├── drug_recalls_helpers.py
│ │ ├── drug_recalls.py
│ │ ├── drug_shortages_detail_helpers.py
│ │ ├── drug_shortages_helpers.py
│ │ ├── drug_shortages.py
│ │ ├── exceptions.py
│ │ ├── input_validation.py
│ │ ├── rate_limiter.py
│ │ ├── utils.py
│ │ └── validation.py
│ ├── organizations
│ │ ├── __init__.py
│ │ ├── getter.py
│ │ └── search.py
│ ├── parameter_parser.py
│ ├── query_parser.py
│ ├── query_router.py
│ ├── rate_limiter.py
│ ├── render.py
│ ├── request_batcher.py
│ ├── resources
│ │ ├── __init__.py
│ │ ├── getter.py
│ │ ├── instructions.md
│ │ └── researcher.md
│ ├── retry.py
│ ├── router_handlers.py
│ ├── router.py
│ ├── shared_context.py
│ ├── thinking
│ │ ├── __init__.py
│ │ ├── sequential.py
│ │ └── session.py
│ ├── thinking_tool.py
│ ├── thinking_tracker.py
│ ├── trials
│ │ ├── __init__.py
│ │ ├── getter.py
│ │ ├── nci_getter.py
│ │ ├── nci_search.py
│ │ └── search.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── cancer_types_api.py
│ │ ├── cbio_http_adapter.py
│ │ ├── endpoint_registry.py
│ │ ├── gene_validator.py
│ │ ├── metrics.py
│ │ ├── mutation_filter.py
│ │ ├── query_utils.py
│ │ ├── rate_limiter.py
│ │ └── request_cache.py
│ ├── variants
│ │ ├── __init__.py
│ │ ├── alphagenome.py
│ │ ├── cancer_types.py
│ │ ├── cbio_external_client.py
│ │ ├── cbioportal_mutations.py
│ │ ├── cbioportal_search_helpers.py
│ │ ├── cbioportal_search.py
│ │ ├── constants.py
│ │ ├── external.py
│ │ ├── filters.py
│ │ ├── getter.py
│ │ ├── links.py
│ │ ├── oncokb_client.py
│ │ ├── oncokb_models.py
│ │ └── search.py
│ └── workers
│ ├── __init__.py
│ ├── worker_entry_stytch.js
│ ├── worker_entry.js
│ └── worker.py
├── tests
│ ├── bdd
│ │ ├── cli_help
│ │ │ ├── help.feature
│ │ │ └── test_help.py
│ │ ├── conftest.py
│ │ ├── features
│ │ │ └── alphagenome_integration.feature
│ │ ├── fetch_articles
│ │ │ ├── fetch.feature
│ │ │ └── test_fetch.py
│ │ ├── get_trials
│ │ │ ├── get.feature
│ │ │ └── test_get.py
│ │ ├── get_variants
│ │ │ ├── get.feature
│ │ │ └── test_get.py
│ │ ├── search_articles
│ │ │ ├── autocomplete.feature
│ │ │ ├── search.feature
│ │ │ ├── test_autocomplete.py
│ │ │ └── test_search.py
│ │ ├── search_trials
│ │ │ ├── search.feature
│ │ │ └── test_search.py
│ │ ├── search_variants
│ │ │ ├── search.feature
│ │ │ └── test_search.py
│ │ └── steps
│ │ └── test_alphagenome_steps.py
│ ├── config
│ │ └── test_smithery_config.py
│ ├── conftest.py
│ ├── data
│ │ ├── ct_gov
│ │ │ ├── clinical_trials_api_v2.yaml
│ │ │ ├── trials_NCT04280705.json
│ │ │ └── trials_NCT04280705.txt
│ │ ├── myvariant
│ │ │ ├── myvariant_api.yaml
│ │ │ ├── myvariant_field_descriptions.csv
│ │ │ ├── variants_full_braf_v600e.json
│ │ │ ├── variants_full_braf_v600e.txt
│ │ │ └── variants_part_braf_v600_multiple.json
│ │ ├── oncokb_mock_responses.json
│ │ ├── openfda
│ │ │ ├── drugsfda_detail.json
│ │ │ ├── drugsfda_search.json
│ │ │ ├── enforcement_detail.json
│ │ │ └── enforcement_search.json
│ │ └── pubtator
│ │ ├── pubtator_autocomplete.json
│ │ └── pubtator3_paper.txt
│ ├── integration
│ │ ├── test_oncokb_integration.py
│ │ ├── test_openfda_integration.py
│ │ ├── test_preprints_integration.py
│ │ ├── test_simple.py
│ │ └── test_variants_integration.py
│ ├── tdd
│ │ ├── articles
│ │ │ ├── test_autocomplete.py
│ │ │ ├── test_cbioportal_integration.py
│ │ │ ├── test_fetch.py
│ │ │ ├── test_preprints.py
│ │ │ ├── test_search.py
│ │ │ └── test_unified.py
│ │ ├── conftest.py
│ │ ├── drugs
│ │ │ ├── __init__.py
│ │ │ └── test_drug_getter.py
│ │ ├── openfda
│ │ │ ├── __init__.py
│ │ │ ├── test_adverse_events.py
│ │ │ ├── test_device_events.py
│ │ │ ├── test_drug_approvals.py
│ │ │ ├── test_drug_labels.py
│ │ │ ├── test_drug_recalls.py
│ │ │ ├── test_drug_shortages.py
│ │ │ └── test_security.py
│ │ ├── test_biothings_integration_real.py
│ │ ├── test_biothings_integration.py
│ │ ├── test_circuit_breaker.py
│ │ ├── test_concurrent_requests.py
│ │ ├── test_connection_pool.py
│ │ ├── test_domain_handlers.py
│ │ ├── test_drug_approvals.py
│ │ ├── test_drug_recalls.py
│ │ ├── test_drug_shortages.py
│ │ ├── test_endpoint_documentation.py
│ │ ├── test_error_scenarios.py
│ │ ├── test_europe_pmc_fetch.py
│ │ ├── test_mcp_integration.py
│ │ ├── test_mcp_tools.py
│ │ ├── test_metrics.py
│ │ ├── test_nci_integration.py
│ │ ├── test_nci_mcp_tools.py
│ │ ├── test_network_policies.py
│ │ ├── test_offline_mode.py
│ │ ├── test_openfda_unified.py
│ │ ├── test_pten_r173_search.py
│ │ ├── test_render.py
│ │ ├── test_request_batcher.py.disabled
│ │ ├── test_retry.py
│ │ ├── test_router.py
│ │ ├── test_shared_context.py.disabled
│ │ ├── test_unified_biothings.py
│ │ ├── thinking
│ │ │ ├── __init__.py
│ │ │ └── test_sequential.py
│ │ ├── trials
│ │ │ ├── test_backward_compatibility.py
│ │ │ ├── test_getter.py
│ │ │ └── test_search.py
│ │ ├── utils
│ │ │ ├── test_gene_validator.py
│ │ │ ├── test_mutation_filter.py
│ │ │ ├── test_rate_limiter.py
│ │ │ └── test_request_cache.py
│ │ ├── variants
│ │ │ ├── constants.py
│ │ │ ├── test_alphagenome_api_key.py
│ │ │ ├── test_alphagenome_comprehensive.py
│ │ │ ├── test_alphagenome.py
│ │ │ ├── test_cbioportal_mutations.py
│ │ │ ├── test_cbioportal_search.py
│ │ │ ├── test_external_integration.py
│ │ │ ├── test_external.py
│ │ │ ├── test_extract_gene_aa_change.py
│ │ │ ├── test_filters.py
│ │ │ ├── test_getter.py
│ │ │ ├── test_links.py
│ │ │ ├── test_oncokb_client.py
│ │ │ ├── test_oncokb_helper.py
│ │ │ └── test_search.py
│ │ └── workers
│ │ └── test_worker_sanitization.js
│ └── test_pydantic_ai_integration.py
├── THIRD_PARTY_ENDPOINTS.md
├── tox.ini
├── uv.lock
└── wrangler.toml
```
# Files
--------------------------------------------------------------------------------
/src/biomcp/workers/worker_entry_stytch.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * BioMCP Worker – With Stytch OAuth (refactored)
3 | */
4 |
5 | import { Hono } from "hono";
6 | import { createRemoteJWKSet, importPKCS8, jwtVerify, SignJWT } from "jose";
7 |
8 | // Configuration variables - will be overridden by env values
9 | let DEBUG = false; // Default value, will be updated from env
10 |
11 | // Constants
12 | const DEFAULT_SESSION_ID = "default";
13 | const MAX_SESSION_ID_LENGTH = 128;
14 |
15 | // Helper functions
16 | const log = (message) => {
17 | if (DEBUG) console.log("[DEBUG]", message);
18 | };
19 |
20 | // List of sensitive fields that should be redacted in logs
21 | const SENSITIVE_FIELDS = [
22 | "api_key",
23 | "apiKey",
24 | "api-key",
25 | "token",
26 | "secret",
27 | "password",
28 | ];
29 |
30 | /**
31 | * Recursively sanitize sensitive fields from an object
32 | * @param {object} obj - Object to sanitize
33 | * @returns {object} - Sanitized copy of the object
34 | */
35 | const sanitizeObject = (obj) => {
36 | if (!obj || typeof obj !== "object") return obj;
37 |
38 | // Handle arrays
39 | if (Array.isArray(obj)) {
40 | return obj.map((item) => sanitizeObject(item));
41 | }
42 |
43 | // Handle objects
44 | const sanitized = {};
45 | for (const [key, value] of Object.entries(obj)) {
46 | // Check if this key is sensitive
47 | const lowerKey = key.toLowerCase();
48 | if (
49 | SENSITIVE_FIELDS.some((field) => lowerKey.includes(field.toLowerCase()))
50 | ) {
51 | sanitized[key] = "[REDACTED]";
52 | } else if (typeof value === "object" && value !== null) {
53 | // Recursively sanitize nested objects
54 | sanitized[key] = sanitizeObject(value);
55 | } else {
56 | sanitized[key] = value;
57 | }
58 | }
59 | return sanitized;
60 | };
61 |
62 | /**
63 | * Validate and sanitize session ID
64 | * @param {string} sessionId - Session ID from query parameter
65 | * @returns {string} - Sanitized session ID or 'default'
66 | */
67 | const validateSessionId = (sessionId) => {
68 | if (!sessionId) return DEFAULT_SESSION_ID;
69 |
70 | // Limit length to prevent DoS
71 | if (sessionId.length > MAX_SESSION_ID_LENGTH) {
72 | log(`Session ID too long (${sessionId.length} chars), using default`);
73 | return DEFAULT_SESSION_ID;
74 | }
75 |
76 | // Remove potentially dangerous characters
77 | const sanitized = sessionId.replace(/[^a-zA-Z0-9\-_]/g, "");
78 | if (sanitized !== sessionId) {
79 | log(`Session ID contained invalid characters, sanitized: ${sanitized}`);
80 | }
81 |
82 | return sanitized || DEFAULT_SESSION_ID;
83 | };
84 |
85 | /**
86 | * Process MCP request with proper error handling
87 | * @param {HonoRequest} request - The incoming Hono request
88 | * @param {string} remoteUrl - Remote MCP server URL
89 | * @param {string} sessionId - Validated session ID
90 | * @returns {Response} - Proxy response or error
91 | */
92 | const processMcpRequest = async (request, remoteUrl, sessionId) => {
93 | try {
94 | // Get body text directly (Hono request doesn't have clone)
95 | const bodyText = await request.text();
96 |
97 | // Validate it's JSON
98 | let bodyJson;
99 | try {
100 | bodyJson = JSON.parse(bodyText);
101 | } catch (e) {
102 | return new Response(
103 | JSON.stringify({
104 | jsonrpc: "2.0",
105 | error: {
106 | code: -32700,
107 | message: "Parse error",
108 | data: "Invalid JSON",
109 | },
110 | }),
111 | { status: 400, headers: { "Content-Type": "application/json" } },
112 | );
113 | }
114 |
115 | // Log sanitized request
116 | const sanitizedBody = sanitizeObject(bodyJson);
117 | log(`MCP POST request body: ${JSON.stringify(sanitizedBody)}`);
118 |
119 | // Validate required JSONRPC fields
120 | if (!bodyJson.jsonrpc || !bodyJson.method) {
121 | return new Response(
122 | JSON.stringify({
123 | jsonrpc: "2.0",
124 | error: {
125 | code: -32600,
126 | message: "Invalid Request",
127 | data: "Missing required fields: jsonrpc, method",
128 | },
129 | }),
130 | { status: 400, headers: { "Content-Type": "application/json" } },
131 | );
132 | }
133 |
134 | // Create a new Request object with the body text since we've already consumed it
135 | const newRequest = new Request(request.url, {
136 | method: "POST",
137 | headers: request.headers,
138 | body: bodyText,
139 | });
140 |
141 | // Forward to remote server
142 | return proxyPost(newRequest, remoteUrl, "/mcp", sessionId);
143 | } catch (error) {
144 | log(`Error processing MCP request: ${error}`);
145 | return new Response(
146 | JSON.stringify({
147 | jsonrpc: "2.0",
148 | error: {
149 | code: -32603,
150 | message: "Internal error",
151 | data: error.message,
152 | },
153 | }),
154 | { status: 500, headers: { "Content-Type": "application/json" } },
155 | );
156 | }
157 | };
158 |
159 | // CORS configuration
160 | const CORS = {
161 | "Access-Control-Allow-Origin": "*",
162 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
163 | "Access-Control-Allow-Headers": "*",
164 | "Access-Control-Max-Age": "86400",
165 | };
166 |
167 | const getStytchUrl = (env, path, isPublic = false) => {
168 | const base = env.STYTCH_API_URL || "https://test.stytch.com/v1";
169 | const projectId = isPublic ? `/public/${env.STYTCH_PROJECT_ID}` : "";
170 | return `${base}${projectId}/${path}`;
171 | };
172 |
173 | // JWT validation logic
174 | let jwks = null;
175 |
176 | /**
177 | * Decode the payload of a JWT (no signature check).
178 | */
179 | function decodeJwt(token) {
180 | try {
181 | const base64Url = token.split(".")[1];
182 | const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
183 | const json = atob(base64);
184 | return JSON.parse(json);
185 | } catch {
186 | return {};
187 | }
188 | }
189 |
190 | let bqTokenPromise = null;
191 |
192 | /**
193 | * Fetch (and cache) a BigQuery OAuth token.
194 | * @param {object} env the Hono env (c.env)
195 | */
196 | async function getBQToken(env) {
197 | // Parse the service‐account JSON key
198 | const key = JSON.parse(env.BQ_SA_KEY_JSON);
199 | const now = Math.floor(Date.now() / 1000);
200 |
201 | // Convert PEM private key string into a CryptoKey
202 | const privateKey = await importPKCS8(key.private_key, "RS256");
203 |
204 | // Build the JWT assertion
205 | const assertion = await new SignJWT({
206 | iss: key.client_email,
207 | scope: "https://www.googleapis.com/auth/bigquery.insertdata",
208 | aud: "https://oauth2.googleapis.com/token",
209 | iat: now,
210 | exp: now + 3600,
211 | })
212 | .setProtectedHeader({ alg: "RS256", kid: key.private_key_id })
213 | .sign(privateKey);
214 |
215 | // Exchange the assertion for an access token
216 | const resp = await fetch("https://oauth2.googleapis.com/token", {
217 | method: "POST",
218 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
219 | body: new URLSearchParams({
220 | grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
221 | assertion,
222 | }),
223 | });
224 | const { access_token } = await resp.json();
225 | return access_token;
226 | }
227 |
228 | /**
229 | * Insert a single row into BigQuery via streaming insert.
230 | * @param {object} env the Hono env (c.env)
231 | * @param {object} row { timestamp, userEmail, query }
232 | */
233 | async function insertEvent(env, row) {
234 | try {
235 | const token = await getBQToken(env);
236 |
237 | const url =
238 | `https://bigquery.googleapis.com/bigquery/v2/projects/` +
239 | `${env.BQ_PROJECT_ID}/datasets/${env.BQ_DATASET}` +
240 | `/tables/${env.BQ_TABLE}/insertAll`;
241 |
242 | const response = await fetch(url, {
243 | method: "POST",
244 | headers: {
245 | Authorization: `Bearer ${token}`,
246 | "Content-Type": "application/json",
247 | },
248 | body: JSON.stringify({ rows: [{ json: row }] }),
249 | });
250 |
251 | if (!response.ok) {
252 | const errorText = await response.text();
253 | throw new Error(`BigQuery API error: ${response.status} - ${errorText}`);
254 | }
255 |
256 | const result = await response.json();
257 | if (result.insertErrors) {
258 | throw new Error(
259 | `BigQuery insert errors: ${JSON.stringify(result.insertErrors)}`,
260 | );
261 | }
262 | } catch (error) {
263 | console.error(`[BigQuery] Insert failed:`, error.message);
264 | throw error;
265 | }
266 | }
267 |
268 | /**
269 | * Validate a JWT token
270 | */
271 | async function validateToken(token, env, expectedIssuer = null) {
272 | if (!token) {
273 | throw new Error("No token provided");
274 | }
275 |
276 | try {
277 | log(`Validating token: ${token.substring(0, 15)}...`);
278 |
279 | // First try to validate as a self-issued JWT
280 | try {
281 | const encoder = new TextEncoder();
282 | const secret = encoder.encode(env.JWT_SECRET || "default-jwt-secret-key");
283 |
284 | // Accept either URL-based issuer or legacy STYTCH_PROJECT_ID issuer
285 | const validIssuers = [expectedIssuer, env.STYTCH_PROJECT_ID].filter(
286 | Boolean,
287 | );
288 | const result = await jwtVerify(token, secret, {
289 | issuer: validIssuers,
290 | });
291 |
292 | // Also check if token exists in KV (for revocation checking)
293 | const tokenHash = await crypto.subtle.digest(
294 | "SHA-256",
295 | encoder.encode(token),
296 | );
297 | const tokenKey = btoa(String.fromCharCode(...new Uint8Array(tokenHash)))
298 | .replace(/\+/g, "-")
299 | .replace(/\//g, "_")
300 | .replace(/=/g, "")
301 | .substring(0, 32);
302 |
303 | const storedToken = await env.OAUTH_KV.get(`token_hash:${tokenKey}`);
304 | if (!storedToken) {
305 | log("Token not found in storage - may have been revoked");
306 | throw new Error("Token not found or revoked");
307 | }
308 |
309 | log("Self-issued JWT validation successful");
310 | return result;
311 | } catch (error) {
312 | log(
313 | `Self-issued JWT validation failed, trying Stytch validation: ${error.message}`,
314 | );
315 |
316 | // If self-validation fails, try Stytch validation as fallback
317 | if (!jwks) {
318 | log("Creating JWKS for Stytch validation");
319 | jwks = createRemoteJWKSet(
320 | new URL(getStytchUrl(env, ".well-known/jwks.json", true)),
321 | );
322 | }
323 |
324 | return await jwtVerify(token, jwks, {
325 | audience: env.STYTCH_PROJECT_ID,
326 | issuer: [`stytch.com/${env.STYTCH_PROJECT_ID}`],
327 | typ: "JWT",
328 | algorithms: ["RS256"],
329 | });
330 | }
331 | } catch (error) {
332 | log(`All token validation methods failed: ${error}`);
333 | throw error;
334 | }
335 | }
336 |
337 | /**
338 | * Function to process the authentication callback
339 | */
340 | async function processAuthCallback(c, token, state, oauthRequest) {
341 | log("Authenticating with Stytch API...");
342 |
343 | try {
344 | // Try to authenticate the token based on token type
345 | const tokenType = "oauth"; // We know it's an OAuth token at this point
346 | let endpoint = "sessions/authenticate";
347 | let payload = { session_token: token };
348 |
349 | if (tokenType === "oauth") {
350 | endpoint = "oauth/authenticate";
351 | payload = { token: token };
352 | }
353 |
354 | log(
355 | `Using Stytch endpoint: ${endpoint} with payload: ${JSON.stringify(
356 | payload,
357 | )}`,
358 | );
359 |
360 | const authenticateResp = await fetch(getStytchUrl(c.env, endpoint), {
361 | method: "POST",
362 | headers: {
363 | "Content-Type": "application/json",
364 | Authorization: `Basic ${btoa(
365 | `${c.env.STYTCH_PROJECT_ID}:${c.env.STYTCH_SECRET}`,
366 | )}`,
367 | },
368 | body: JSON.stringify(payload),
369 | });
370 |
371 | log(`Stytch auth response status: ${authenticateResp.status}`);
372 |
373 | if (!authenticateResp.ok) {
374 | const errorText = await authenticateResp.text();
375 | log(`Stytch authentication error: ${errorText}`);
376 | return new Response(`Authentication failed: ${errorText}`, {
377 | status: 401,
378 | headers: CORS,
379 | });
380 | }
381 |
382 | const authData = await authenticateResp.json();
383 | log(
384 | `Auth data received: ${JSON.stringify({
385 | user_id: authData.user_id || "unknown",
386 | has_user: !!authData.user,
387 | })}`,
388 | );
389 |
390 | // Generate an authorization code
391 | const authCode = crypto.randomUUID();
392 | log(`Generated authorization code: ${authCode}`);
393 |
394 | // Store the user info with the authorization code
395 | const authCodeData = {
396 | sub: authData.user_id,
397 | email: authData.user?.emails?.[0]?.email,
398 | code_challenge: oauthRequest.code_challenge,
399 | client_id: oauthRequest.client_id,
400 | redirect_uri: oauthRequest.redirect_uri,
401 | };
402 |
403 | log(`Storing auth code data: ${JSON.stringify(authCodeData)}`);
404 | await c.env.OAUTH_KV.put(
405 | `auth_code:${authCode}`,
406 | JSON.stringify(authCodeData),
407 | { expirationTtl: 300 },
408 | );
409 | log("Successfully stored auth code data");
410 |
411 | // Determine the redirect URI to use
412 | if (!oauthRequest.redirect_uri) {
413 | log("Missing redirect_uri - using default");
414 | return new Response("Missing redirect URI in OAuth request", {
415 | status: 400,
416 | headers: CORS,
417 | });
418 | }
419 |
420 | log(`Using redirect URI from request: ${oauthRequest.redirect_uri}`);
421 | log(`Using state for redirect: ${state}`);
422 |
423 | const redirectURL = new URL(oauthRequest.redirect_uri);
424 | redirectURL.searchParams.set("code", authCode);
425 | redirectURL.searchParams.set("state", state);
426 |
427 | log(`Redirecting to: ${redirectURL.toString()}`);
428 | return Response.redirect(redirectURL.toString(), 302);
429 | } catch (error) {
430 | console.error(`Error in processAuthCallback: ${error}`);
431 | return new Response(`Authentication processing error: ${error.message}`, {
432 | status: 500,
433 | headers: CORS,
434 | });
435 | }
436 | }
437 |
438 | // Function to proxy POST requests to remote MCP server
439 | async function proxyPost(req, remoteServerUrl, path, sid) {
440 | const body = await req.text();
441 | const targetUrl = `${remoteServerUrl}${path}?session_id=${encodeURIComponent(
442 | sid,
443 | )}`;
444 |
445 | // Streamable HTTP requires both application/json and text/event-stream
446 | // The server will decide which format to use based on the response type
447 | const acceptHeader = "application/json, text/event-stream";
448 |
449 | const headers = {
450 | "Content-Type": "application/json",
451 | Accept: acceptHeader,
452 | "User-Agent": "Claude/1.0",
453 | };
454 |
455 | try {
456 | const response = await fetch(targetUrl, {
457 | method: "POST",
458 | headers: headers,
459 | body: body,
460 | });
461 |
462 | const responseText = await response.text();
463 | log(`Proxy response from ${targetUrl}: ${responseText.substring(0, 500)}`);
464 |
465 | // Check if response is SSE format
466 | if (
467 | responseText.startsWith("event:") ||
468 | responseText.includes("\nevent:")
469 | ) {
470 | // Parse SSE format
471 | const events = responseText.split("\n\n").filter((e) => e.trim());
472 |
473 | if (events.length === 1) {
474 | // Single SSE event - convert to plain JSON
475 | const lines = events[0].split("\n");
476 | const dataLine = lines.find((l) => l.startsWith("data:"));
477 |
478 | if (dataLine) {
479 | const jsonData = dataLine.substring(5).trim(); // Remove "data:" prefix
480 | log("Converting single SSE message to plain JSON");
481 | return new Response(jsonData, {
482 | status: response.status,
483 | headers: { "Content-Type": "application/json", ...CORS },
484 | });
485 | }
486 | } else if (events.length > 1) {
487 | // Multiple SSE events - return as SSE stream
488 | log("Returning multiple SSE messages as stream");
489 | return new Response(responseText, {
490 | status: response.status,
491 | headers: {
492 | "Content-Type": "text/event-stream",
493 | "Cache-Control": "no-cache",
494 | ...CORS,
495 | },
496 | });
497 | }
498 | }
499 |
500 | // Not SSE format - return as-is
501 | return new Response(responseText, {
502 | status: response.status,
503 | headers: { "Content-Type": "application/json", ...CORS },
504 | });
505 | } catch (error) {
506 | log(`Proxy fetch error: ${error.message}`);
507 | return new Response(JSON.stringify({ error: error.message }), {
508 | status: 502,
509 | headers: { "Content-Type": "application/json", ...CORS },
510 | });
511 | }
512 | }
513 |
514 | // Middleware for bearer token authentication (MCP server)
515 | const stytchBearerTokenAuthMiddleware = async (c, next) => {
516 | const authHeader = c.req.header("Authorization");
517 | log(`Auth header present: ${!!authHeader}`);
518 |
519 | if (!authHeader || !authHeader.startsWith("Bearer ")) {
520 | const url = new URL(c.req.url);
521 | return new Response(JSON.stringify({ error: "unauthorized" }), {
522 | status: 401,
523 | headers: {
524 | "Content-Type": "application/json",
525 | "WWW-Authenticate": `Bearer realm="${url.origin}", error="invalid_token"`,
526 | ...CORS,
527 | },
528 | });
529 | }
530 |
531 | const accessToken = authHeader.substring(7);
532 | log(`Attempting to validate token: ${accessToken.substring(0, 10)}...`);
533 |
534 | try {
535 | // Add more detailed validation logging
536 | log("Starting token validation...");
537 | const url = new URL(c.req.url);
538 | const verifyResult = await validateToken(accessToken, c.env, url.origin);
539 | log(`Token validation successful! ${verifyResult.payload.sub}`);
540 |
541 | // Store user info in a variable that the handler can access
542 | c.env.userID = verifyResult.payload.sub;
543 | c.env.accessToken = accessToken;
544 | } catch (error) {
545 | log(`Token validation detailed error: ${error.code} ${error.message}`);
546 | const url = new URL(c.req.url);
547 | return new Response(
548 | JSON.stringify({
549 | error: "invalid_token",
550 | error_description: error.message,
551 | }),
552 | {
553 | status: 401,
554 | headers: {
555 | "Content-Type": "application/json",
556 | "WWW-Authenticate": `Bearer realm="${url.origin}", error="invalid_token"`,
557 | ...CORS,
558 | },
559 | },
560 | );
561 | }
562 |
563 | return next();
564 | };
565 |
566 | // Create our main app with Hono
567 | const app = new Hono();
568 |
569 | // Configure the routes
570 | app
571 | // Error handler
572 | .onError((err, c) => {
573 | console.error(`Application error: ${err}`);
574 | return new Response("Server error", {
575 | status: 500,
576 | headers: CORS,
577 | });
578 | })
579 |
580 | // Handle CORS preflight requests
581 | .options("*", (c) => new Response(null, { status: 204, headers: CORS }))
582 |
583 | // Status endpoints
584 | .get("/status", (c) => {
585 | const REMOTE_MCP_SERVER_URL =
586 | c.env.REMOTE_MCP_SERVER_URL || "http://localhost:8000";
587 | return new Response(
588 | JSON.stringify({
589 | worker: "BioMCP-OAuth",
590 | remote: REMOTE_MCP_SERVER_URL,
591 | forwardPath: "/messages",
592 | resourceEndpoint: null,
593 | debug: DEBUG,
594 | }),
595 | {
596 | status: 200,
597 | headers: { "Content-Type": "application/json", ...CORS },
598 | },
599 | );
600 | })
601 |
602 | .get("/debug", (c) => {
603 | const REMOTE_MCP_SERVER_URL =
604 | c.env.REMOTE_MCP_SERVER_URL || "http://localhost:8000";
605 | return new Response(
606 | JSON.stringify({
607 | worker: "BioMCP-OAuth",
608 | remote: REMOTE_MCP_SERVER_URL,
609 | forwardPath: "/messages",
610 | resourceEndpoint: null,
611 | debug: DEBUG,
612 | }),
613 | {
614 | status: 200,
615 | headers: { "Content-Type": "application/json", ...CORS },
616 | },
617 | );
618 | })
619 |
620 | // UserInfo endpoint (OIDC)
621 | .get("/userinfo", async (c) => {
622 | const authHeader = c.req.header("Authorization");
623 | if (!authHeader || !authHeader.startsWith("Bearer ")) {
624 | return new Response(JSON.stringify({ error: "unauthorized" }), {
625 | status: 401,
626 | headers: { "Content-Type": "application/json", ...CORS },
627 | });
628 | }
629 |
630 | const token = authHeader.substring(7);
631 | try {
632 | const url = new URL(c.req.url);
633 | const result = await validateToken(token, c.env, url.origin);
634 | return new Response(
635 | JSON.stringify({
636 | sub: result.payload.sub,
637 | email: result.payload.email,
638 | }),
639 | {
640 | status: 200,
641 | headers: { "Content-Type": "application/json", ...CORS },
642 | },
643 | );
644 | } catch (error) {
645 | return new Response(JSON.stringify({ error: "invalid_token" }), {
646 | status: 401,
647 | headers: { "Content-Type": "application/json", ...CORS },
648 | });
649 | }
650 | })
651 |
652 | // OpenID Connect discovery endpoint
653 | .get("/.well-known/openid-configuration", (c) => {
654 | const url = new URL(c.req.url);
655 | return new Response(
656 | JSON.stringify({
657 | issuer: url.origin,
658 | authorization_endpoint: `${url.origin}/authorize`,
659 | token_endpoint: `${url.origin}/token`,
660 | userinfo_endpoint: `${url.origin}/userinfo`,
661 | jwks_uri: getStytchUrl(c.env, ".well-known/jwks.json", true),
662 | registration_endpoint: getStytchUrl(c.env, "oauth2/register", true),
663 | scopes_supported: ["openid", "profile", "email", "offline_access"],
664 | response_types_supported: ["code"],
665 | response_modes_supported: ["query"],
666 | grant_types_supported: ["authorization_code", "refresh_token"],
667 | subject_types_supported: ["public"],
668 | id_token_signing_alg_values_supported: ["RS256"],
669 | token_endpoint_auth_methods_supported: ["none"],
670 | code_challenge_methods_supported: ["S256"],
671 | }),
672 | {
673 | status: 200,
674 | headers: { "Content-Type": "application/json", ...CORS },
675 | },
676 | );
677 | })
678 |
679 | // OAuth protected resource metadata (RFC 9728)
680 | // Tells clients which authorization server protects this resource
681 | .get("/.well-known/oauth-protected-resource", (c) => {
682 | const url = new URL(c.req.url);
683 | return new Response(
684 | JSON.stringify({
685 | resource: `${url.origin}/mcp`,
686 | authorization_servers: [`${url.origin}`],
687 | bearer_methods_supported: ["header"],
688 | scopes_supported: ["openid", "profile", "email"],
689 | }),
690 | {
691 | status: 200,
692 | headers: { "Content-Type": "application/json", ...CORS },
693 | },
694 | );
695 | })
696 |
697 | // Handle path-specific protected resource metadata (e.g., /.well-known/oauth-protected-resource/mcp)
698 | .get("/.well-known/oauth-protected-resource/*", (c) => {
699 | const url = new URL(c.req.url);
700 | return new Response(
701 | JSON.stringify({
702 | resource: `${url.origin}/mcp`,
703 | authorization_servers: [`${url.origin}`],
704 | bearer_methods_supported: ["header"],
705 | scopes_supported: ["openid", "profile", "email"],
706 | }),
707 | {
708 | status: 200,
709 | headers: { "Content-Type": "application/json", ...CORS },
710 | },
711 | );
712 | })
713 |
714 | // OAuth server metadata endpoint
715 | .get("/.well-known/oauth-authorization-server", (c) => {
716 | const url = new URL(c.req.url);
717 | return new Response(
718 | JSON.stringify({
719 | issuer: url.origin,
720 | authorization_endpoint: `${url.origin}/authorize`,
721 | token_endpoint: `${url.origin}/token`,
722 | registration_endpoint: getStytchUrl(c.env, "oauth2/register", true),
723 | scopes_supported: ["openid", "profile", "email", "offline_access"],
724 | response_types_supported: ["code"],
725 | response_modes_supported: ["query"],
726 | grant_types_supported: ["authorization_code", "refresh_token"],
727 | token_endpoint_auth_methods_supported: ["none"],
728 | code_challenge_methods_supported: ["S256"],
729 | }),
730 | {
731 | status: 200,
732 | headers: { "Content-Type": "application/json", ...CORS },
733 | },
734 | );
735 | })
736 |
737 | // Path-specific OAuth server metadata (e.g., /.well-known/oauth-authorization-server/mcp)
738 | .get("/.well-known/oauth-authorization-server/*", (c) => {
739 | const url = new URL(c.req.url);
740 | return new Response(
741 | JSON.stringify({
742 | issuer: url.origin,
743 | authorization_endpoint: `${url.origin}/authorize`,
744 | token_endpoint: `${url.origin}/token`,
745 | registration_endpoint: getStytchUrl(c.env, "oauth2/register", true),
746 | scopes_supported: ["openid", "profile", "email", "offline_access"],
747 | response_types_supported: ["code"],
748 | response_modes_supported: ["query"],
749 | grant_types_supported: ["authorization_code", "refresh_token"],
750 | token_endpoint_auth_methods_supported: ["none"],
751 | code_challenge_methods_supported: ["S256"],
752 | }),
753 | {
754 | status: 200,
755 | headers: { "Content-Type": "application/json", ...CORS },
756 | },
757 | );
758 | })
759 |
760 | // OAuth redirect endpoint (redirects to Stytch's hosted UI)
761 | .get("/authorize", async (c) => {
762 | try {
763 | log("Authorize endpoint hit");
764 | const url = new URL(c.req.url);
765 | log(`Full authorize URL: ${url.toString()}`);
766 | log(
767 | `Search params: ${JSON.stringify(
768 | Object.fromEntries(url.searchParams),
769 | )}`,
770 | );
771 |
772 | const redirectUrl = new URL("/callback", url.origin).toString();
773 | log(`Redirect URL: ${redirectUrl}`);
774 |
775 | // Extract and forward OAuth parameters
776 | const clientId = url.searchParams.get("client_id") || "unknown_client";
777 | const redirectUri = url.searchParams.get("redirect_uri");
778 | let state = url.searchParams.get("state");
779 | const codeChallenge = url.searchParams.get("code_challenge");
780 | const codeChallengeMethod = url.searchParams.get("code_challenge_method");
781 |
782 | // Generate a state if one isn't provided
783 | if (!state) {
784 | state = crypto.randomUUID();
785 | log(`Generated state parameter: ${state}`);
786 | }
787 |
788 | log("OAuth params:", {
789 | clientId,
790 | redirectUri,
791 | state,
792 | codeChallenge: !!codeChallenge,
793 | codeChallengeMethod,
794 | });
795 |
796 | // Store OAuth request parameters in KV for use during callback
797 | const oauthRequestData = {
798 | client_id: clientId,
799 | redirect_uri: redirectUri,
800 | code_challenge: codeChallenge,
801 | code_challenge_method: codeChallengeMethod,
802 | original_state: state, // Store the original state explicitly
803 | };
804 |
805 | // Also store a mapping from any state value to the original state
806 | // This is crucial for handling cases where Stytch modifies the state
807 | try {
808 | // Use a consistent key based on timestamp for lookups
809 | const timestamp = Date.now().toString();
810 | await c.env.OAUTH_KV.put(`state_timestamp:${timestamp}`, state, {
811 | expirationTtl: 600,
812 | });
813 |
814 | log(`Saving OAuth request data: ${JSON.stringify(oauthRequestData)}`);
815 | await c.env.OAUTH_KV.put(
816 | `oauth_request:${state}`,
817 | JSON.stringify(oauthRequestData),
818 | { expirationTtl: 600 },
819 | );
820 |
821 | // Also store timestamp for this state to allow fallback lookup
822 | await c.env.OAUTH_KV.put(`timestamp_for_state:${state}`, timestamp, {
823 | expirationTtl: 600,
824 | });
825 |
826 | log("Successfully stored OAuth request data in KV");
827 | } catch (kvError) {
828 | log(`Error storing OAuth data in KV: ${kvError}`);
829 | return new Response("Internal server error storing OAuth data", {
830 | status: 500,
831 | headers: CORS,
832 | });
833 | }
834 |
835 | // Redirect to Stytch's hosted login UI
836 | const stytchLoginUrl = `${
837 | c.env.STYTCH_OAUTH_URL ||
838 | "https://test.stytch.com/v1/public/oauth/google/start"
839 | }?public_token=${
840 | c.env.STYTCH_PUBLIC_TOKEN
841 | }&login_redirect_url=${encodeURIComponent(
842 | redirectUrl,
843 | )}&state=${encodeURIComponent(state)}`;
844 |
845 | log(`Redirecting to Stytch: ${stytchLoginUrl}`);
846 | return Response.redirect(stytchLoginUrl, 302);
847 | } catch (error) {
848 | console.error(`Error in authorize endpoint: ${error}`);
849 | return new Response(`Authorization error: ${error.message}`, {
850 | status: 500,
851 | headers: CORS,
852 | });
853 | }
854 | })
855 |
856 | // OAuth callback endpoint
857 | .get("/callback", async (c) => {
858 | try {
859 | log("Callback hit, logging all details");
860 | const url = new URL(c.req.url);
861 | log(`Full URL: ${url.toString()}`);
862 | log(
863 | `Search params: ${JSON.stringify(
864 | Object.fromEntries(url.searchParams),
865 | )}`,
866 | );
867 |
868 | // Stytch's callback format - get the token
869 | const token =
870 | url.searchParams.get("stytch_token_type") === "oauth"
871 | ? url.searchParams.get("token")
872 | : url.searchParams.get("token") ||
873 | url.searchParams.get("stytch_token");
874 |
875 | log(`Token type: ${url.searchParams.get("stytch_token_type")}`);
876 | log(`Token found: ${!!token}`);
877 |
878 | // We need a token to proceed
879 | if (!token) {
880 | log("Invalid callback - missing token");
881 | return new Response("Invalid callback request: missing token", {
882 | status: 400,
883 | headers: CORS,
884 | });
885 | }
886 |
887 | // Look for the most recent OAuth request
888 | let mostRecentState = null;
889 | let mostRecentTimestamp = null;
890 | try {
891 | // Find the most recent timestamp
892 | const timestamps = await c.env.OAUTH_KV.list({
893 | prefix: "state_timestamp:",
894 | });
895 | if (timestamps.keys.length > 0) {
896 | // Sort timestamps in descending order (most recent first)
897 | const sortedTimestamps = timestamps.keys.sort((a, b) => {
898 | const timeA = parseInt(a.name.replace("state_timestamp:", ""));
899 | const timeB = parseInt(b.name.replace("state_timestamp:", ""));
900 | return timeB - timeA; // descending order
901 | });
902 |
903 | mostRecentTimestamp = sortedTimestamps[0].name;
904 | // Get the state associated with this timestamp
905 | mostRecentState = await c.env.OAUTH_KV.get(mostRecentTimestamp);
906 | log(`Found most recent state: ${mostRecentState}`);
907 | }
908 | } catch (error) {
909 | log(`Error finding recent state: ${error}`);
910 | }
911 |
912 | // If we have a state from the most recent OAuth request, use it
913 | let oauthRequest = null;
914 | let state = mostRecentState;
915 |
916 | if (state) {
917 | try {
918 | const oauthRequestJson = await c.env.OAUTH_KV.get(
919 | `oauth_request:${state}`,
920 | );
921 | if (oauthRequestJson) {
922 | oauthRequest = JSON.parse(oauthRequestJson);
923 | log(`Found OAuth request for state: ${state}`);
924 | }
925 | } catch (error) {
926 | log(`Error getting OAuth request: ${error}`);
927 | }
928 | }
929 |
930 | // If we couldn't find the OAuth request, try other alternatives
931 | if (!oauthRequest) {
932 | log(
933 | "No OAuth request found for most recent state, checking other requests",
934 | );
935 |
936 | try {
937 | // List all OAuth requests and use the most recent one
938 | const requests = await c.env.OAUTH_KV.list({
939 | prefix: "oauth_request:",
940 | });
941 | if (requests.keys.length > 0) {
942 | const oauthRequestJson = await c.env.OAUTH_KV.get(
943 | requests.keys[0].name,
944 | );
945 | if (oauthRequestJson) {
946 | oauthRequest = JSON.parse(oauthRequestJson);
947 | // Extract the state from the key
948 | state = requests.keys[0].name.replace("oauth_request:", "");
949 | log(`Using most recent OAuth request with state: ${state}`);
950 | }
951 | }
952 | } catch (error) {
953 | log(`Error finding alternative OAuth request: ${error}`);
954 | }
955 | }
956 |
957 | // Final fallback - use hardcoded values for Claude
958 | if (!oauthRequest) {
959 | log("No OAuth request found, using fallback values");
960 | oauthRequest = {
961 | client_id: "biomcp-client",
962 | redirect_uri: "https://claude.ai/api/mcp/auth_callback",
963 | code_challenge: null,
964 | original_state: state || "unknown_state",
965 | };
966 | }
967 |
968 | // If we have an original_state in the OAuth request, use that
969 | if (oauthRequest.original_state) {
970 | state = oauthRequest.original_state;
971 | log(`Using original state from OAuth request: ${state}`);
972 | }
973 |
974 | // Proceed with authentication
975 | return processAuthCallback(c, token, state, oauthRequest);
976 | } catch (error) {
977 | console.error(`Callback error: ${error}`);
978 | return new Response(
979 | `Server error during authentication: ${error.message}`,
980 | {
981 | status: 500,
982 | headers: CORS,
983 | },
984 | );
985 | }
986 | })
987 |
988 | // Token exchange endpoint
989 | .post("/token", async (c) => {
990 | try {
991 | log("Token endpoint hit");
992 | const formData = await c.req.formData();
993 | const grantType = formData.get("grant_type");
994 | const code = formData.get("code");
995 | const redirectUri = formData.get("redirect_uri");
996 | const clientId = formData.get("client_id");
997 | const codeVerifier = formData.get("code_verifier");
998 |
999 | log("Token request params:", {
1000 | grantType,
1001 | code: !!code,
1002 | redirectUri,
1003 | clientId,
1004 | codeVerifier: !!codeVerifier,
1005 | });
1006 |
1007 | if (
1008 | grantType !== "authorization_code" ||
1009 | !code ||
1010 | !redirectUri ||
1011 | !clientId ||
1012 | !codeVerifier
1013 | ) {
1014 | log("Invalid token request parameters");
1015 | return new Response(JSON.stringify({ error: "invalid_request" }), {
1016 | status: 400,
1017 | headers: { "Content-Type": "application/json", ...CORS },
1018 | });
1019 | }
1020 |
1021 | // Retrieve the stored authorization code data
1022 | let authCodeJson;
1023 | try {
1024 | authCodeJson = await c.env.OAUTH_KV.get(`auth_code:${code}`);
1025 | log(`Auth code data retrieved: ${!!authCodeJson}`);
1026 | } catch (kvError) {
1027 | log(`Error retrieving auth code data: ${kvError}`);
1028 | return new Response(JSON.stringify({ error: "server_error" }), {
1029 | status: 500,
1030 | headers: { "Content-Type": "application/json", ...CORS },
1031 | });
1032 | }
1033 |
1034 | if (!authCodeJson) {
1035 | log("Invalid or expired authorization code");
1036 | return new Response(JSON.stringify({ error: "invalid_grant" }), {
1037 | status: 400,
1038 | headers: { "Content-Type": "application/json", ...CORS },
1039 | });
1040 | }
1041 |
1042 | let authCodeData;
1043 | try {
1044 | authCodeData = JSON.parse(authCodeJson);
1045 | log(`Auth code data parsed: ${JSON.stringify(authCodeData)}`);
1046 | } catch (parseError) {
1047 | log(`Error parsing auth code data: ${parseError}`);
1048 | return new Response(JSON.stringify({ error: "server_error" }), {
1049 | status: 500,
1050 | headers: { "Content-Type": "application/json", ...CORS },
1051 | });
1052 | }
1053 |
1054 | // Verify the code_verifier against the stored code_challenge
1055 | if (authCodeData.code_challenge) {
1056 | log("Verifying PKCE code challenge");
1057 | const encoder = new TextEncoder();
1058 | const data = encoder.encode(codeVerifier);
1059 | const digest = await crypto.subtle.digest("SHA-256", data);
1060 |
1061 | // Convert to base64url encoding
1062 | const base64Digest = btoa(
1063 | String.fromCharCode(...new Uint8Array(digest)),
1064 | )
1065 | .replace(/\+/g, "-")
1066 | .replace(/\//g, "_")
1067 | .replace(/=/g, "");
1068 |
1069 | log("Code challenge comparison:", {
1070 | stored: authCodeData.code_challenge,
1071 | computed: base64Digest,
1072 | match: base64Digest === authCodeData.code_challenge,
1073 | });
1074 |
1075 | if (base64Digest !== authCodeData.code_challenge) {
1076 | log("PKCE verification failed");
1077 | return new Response(JSON.stringify({ error: "invalid_grant" }), {
1078 | status: 400,
1079 | headers: { "Content-Type": "application/json", ...CORS },
1080 | });
1081 | }
1082 | }
1083 |
1084 | // Delete the used authorization code
1085 | try {
1086 | await c.env.OAUTH_KV.delete(`auth_code:${code}`);
1087 | log("Used authorization code deleted");
1088 | } catch (deleteError) {
1089 | log(`Error deleting used auth code: ${deleteError}`);
1090 | // Continue anyway since this isn't critical
1091 | }
1092 |
1093 | // Generate JWT access token instead of UUID
1094 | const encoder = new TextEncoder();
1095 | const secret = encoder.encode(
1096 | c.env.JWT_SECRET || "default-jwt-secret-key",
1097 | );
1098 |
1099 | // Create JWT payload - use origin URL as issuer to match discovery metadata
1100 | const tokenUrl = new URL(c.req.url);
1101 | const accessTokenPayload = {
1102 | sub: authCodeData.sub,
1103 | email: authCodeData.email,
1104 | client_id: clientId,
1105 | scope: "openid profile email",
1106 | iss: tokenUrl.origin,
1107 | aud: clientId,
1108 | exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiry
1109 | iat: Math.floor(Date.now() / 1000),
1110 | };
1111 |
1112 | // Sign JWT
1113 | const accessToken = await new SignJWT(accessTokenPayload)
1114 | .setProtectedHeader({ alg: "HS256" })
1115 | .setIssuedAt()
1116 | .setExpirationTime("1h")
1117 | .sign(secret);
1118 |
1119 | log(`Generated JWT access token: ${accessToken.substring(0, 20)}...`);
1120 |
1121 | // Generate refresh token (still using UUID for simplicity)
1122 | const refreshToken = crypto.randomUUID();
1123 |
1124 | // Store token information - use a hash of the token as the key to avoid length limits
1125 | const tokenHash = await crypto.subtle.digest(
1126 | "SHA-256",
1127 | encoder.encode(accessToken),
1128 | );
1129 | const tokenKey = btoa(String.fromCharCode(...new Uint8Array(tokenHash)))
1130 | .replace(/\+/g, "-")
1131 | .replace(/\//g, "_")
1132 | .replace(/=/g, "")
1133 | .substring(0, 32); // Use first 32 chars of hash
1134 |
1135 | try {
1136 | log(`Storing access token with key: access_token:${tokenKey}`);
1137 | await c.env.OAUTH_KV.put(
1138 | `access_token:${tokenKey}`,
1139 | JSON.stringify({
1140 | token: accessToken,
1141 | hash: tokenKey,
1142 | ...accessTokenPayload,
1143 | }),
1144 | { expirationTtl: 3600 },
1145 | );
1146 |
1147 | // Also store a mapping from the full token to the hash for validation
1148 | await c.env.OAUTH_KV.put(`token_hash:${tokenKey}`, accessToken, {
1149 | expirationTtl: 3600,
1150 | });
1151 |
1152 | log("Storing refresh token");
1153 | await c.env.OAUTH_KV.put(
1154 | `refresh_token:${refreshToken}`,
1155 | JSON.stringify({
1156 | sub: authCodeData.sub,
1157 | client_id: clientId,
1158 | }),
1159 | { expirationTtl: 30 * 24 * 60 * 60 },
1160 | );
1161 |
1162 | log("Token data successfully stored");
1163 | } catch (storeError) {
1164 | log(`Error storing token data: ${storeError}`);
1165 | return new Response(JSON.stringify({ error: "server_error" }), {
1166 | status: 500,
1167 | headers: { "Content-Type": "application/json", ...CORS },
1168 | });
1169 | }
1170 |
1171 | // Return the tokens
1172 | const tokenResponse = {
1173 | access_token: accessToken,
1174 | token_type: "Bearer",
1175 | expires_in: 3600,
1176 | refresh_token: refreshToken,
1177 | scope: "openid profile email",
1178 | };
1179 |
1180 | log("Returning token response");
1181 | return new Response(JSON.stringify(tokenResponse), {
1182 | status: 200,
1183 | headers: { "Content-Type": "application/json", ...CORS },
1184 | });
1185 | } catch (error) {
1186 | console.error(`Token endpoint error: ${error}`);
1187 | return new Response(JSON.stringify({ error: "server_error" }), {
1188 | status: 500,
1189 | headers: { "Content-Type": "application/json", ...CORS },
1190 | });
1191 | }
1192 | })
1193 |
1194 | // Messages endpoint for all paths that start with /messages
1195 | .post("/messages*", async (c) => {
1196 | log("All messages endpoints hit");
1197 | const REMOTE_MCP_SERVER_URL =
1198 | c.env.REMOTE_MCP_SERVER_URL || "http://localhost:8000";
1199 | const sid = new URL(c.req.url).searchParams.get("session_id");
1200 |
1201 | if (!sid) {
1202 | return new Response("Missing session_id", {
1203 | status: 400,
1204 | headers: CORS,
1205 | });
1206 | }
1207 |
1208 | // Read the body
1209 | const body = await c.req.text();
1210 | const authHeader = c.req.header("Authorization") || "";
1211 | let userEmail = "unknown";
1212 |
1213 | if (authHeader.startsWith("Bearer ")) {
1214 | const token = authHeader.slice(7);
1215 | const claims = decodeJwt(token);
1216 | userEmail =
1217 | claims.email || claims.preferred_username || claims.sub || "unknown";
1218 | }
1219 |
1220 | log(`[Proxy] user=${userEmail} query=${body}`);
1221 |
1222 | let sendToBQ = false;
1223 | let parsed;
1224 | let domain = null;
1225 | let toolName = null;
1226 | let sanitizedBody = body; // Default to original body
1227 |
1228 | try {
1229 | parsed = JSON.parse(body);
1230 | const args = parsed.params?.arguments;
1231 |
1232 | // Check if this is a think tool call
1233 | toolName = parsed.params?.name;
1234 | if (toolName === "think") {
1235 | sendToBQ = false;
1236 | log("[BigQuery] Skipping think tool call");
1237 | } else if (args && Object.keys(args).length > 0) {
1238 | // Extract domain from the arguments (for search/fetch tools)
1239 | domain = args.domain || null;
1240 |
1241 | // Skip logging if domain is "thinking" or "think"
1242 | if (domain === "thinking" || domain === "think") {
1243 | sendToBQ = false;
1244 | } else {
1245 | sendToBQ = true;
1246 | }
1247 |
1248 | // Sanitize sensitive data before logging to BigQuery
1249 | if (sendToBQ) {
1250 | // Use the comprehensive sanitization function
1251 | const sanitized = sanitizeObject(parsed);
1252 | sanitizedBody = JSON.stringify(sanitized);
1253 |
1254 | // Log if we actually sanitized something
1255 | if (JSON.stringify(parsed) !== sanitizedBody) {
1256 | log(
1257 | "[BigQuery] Sanitized sensitive fields from query before logging",
1258 | );
1259 | }
1260 | }
1261 | }
1262 | } catch (e) {
1263 | console.log("[BigQuery] skipping insert—cannot parse JSON body", e);
1264 | }
1265 |
1266 | const { BQ_SA_KEY_JSON, BQ_PROJECT_ID, BQ_DATASET, BQ_TABLE } = c.env;
1267 |
1268 | if (sendToBQ && BQ_SA_KEY_JSON && BQ_PROJECT_ID && BQ_DATASET && BQ_TABLE) {
1269 | const eventRow = {
1270 | timestamp: new Date().toISOString(),
1271 | userEmail,
1272 | query: sanitizedBody, // Use sanitized body instead of original
1273 | };
1274 | // fire & forget
1275 | c.executionCtx.waitUntil(
1276 | insertEvent(c.env, eventRow).catch((error) => {
1277 | console.error("[BigQuery] Insert failed:", error);
1278 | }),
1279 | );
1280 | } else {
1281 | const missing = [
1282 | !sendToBQ
1283 | ? toolName === "think"
1284 | ? "think tool"
1285 | : domain === "thinking" || domain === "think"
1286 | ? `domain is ${domain}`
1287 | : "no query args"
1288 | : null,
1289 | !BQ_SA_KEY_JSON && "BQ_SA_KEY_JSON",
1290 | !BQ_PROJECT_ID && "BQ_PROJECT_ID",
1291 | !BQ_DATASET && "BQ_DATASET",
1292 | !BQ_TABLE && "BQ_TABLE",
1293 | ].filter(Boolean);
1294 | console.log("[BigQuery] skipping insert—", missing.join(", "));
1295 | }
1296 |
1297 | // Make a new Request object with the body we've already read
1298 | const newRequest = new Request(c.req.url, {
1299 | method: c.req.method,
1300 | headers: c.req.headers,
1301 | body: body,
1302 | });
1303 |
1304 | // Forward everything to proxyPost like the auth-less version does
1305 | return proxyPost(newRequest, REMOTE_MCP_SERVER_URL, "/messages", sid);
1306 | });
1307 |
1308 | // MCP endpoint (Streamable HTTP transport) - separate chain to avoid wildcard route issues
1309 | app
1310 | .on("HEAD", "/mcp", stytchBearerTokenAuthMiddleware, (c) => {
1311 | log("MCP HEAD endpoint hit - checking endpoint availability");
1312 | // For Streamable HTTP, HEAD /mcp should return 204 to indicate the endpoint exists
1313 | return new Response(null, {
1314 | status: 204,
1315 | headers: CORS,
1316 | });
1317 | })
1318 | .get("/mcp", stytchBearerTokenAuthMiddleware, async (c) => {
1319 | log("MCP GET endpoint hit - Streamable HTTP transport");
1320 | const REMOTE_MCP_SERVER_URL =
1321 | c.env.REMOTE_MCP_SERVER_URL || "http://localhost:8000";
1322 |
1323 | // For Streamable HTTP, GET /mcp with session_id initiates event stream
1324 | const sessionId = new URL(c.req.url).searchParams.get("session_id");
1325 |
1326 | if (!sessionId) {
1327 | // Without session_id, just return 204 to indicate endpoint exists
1328 | return new Response(null, {
1329 | status: 204,
1330 | headers: CORS,
1331 | });
1332 | }
1333 |
1334 | // Proxy the GET request to the backend's /mcp endpoint for streaming
1335 | const targetUrl = `${REMOTE_MCP_SERVER_URL}/mcp?session_id=${encodeURIComponent(
1336 | sessionId,
1337 | )}`;
1338 | log(`Proxying GET /mcp to: ${targetUrl}`);
1339 |
1340 | try {
1341 | const response = await fetch(targetUrl, {
1342 | method: "GET",
1343 | headers: {
1344 | Accept: "text/event-stream",
1345 | "User-Agent": "Claude/1.0",
1346 | },
1347 | });
1348 |
1349 | // For SSE, we need to stream the response
1350 | if (response.headers.get("content-type")?.includes("text/event-stream")) {
1351 | log("Streaming SSE response from backend");
1352 | // Return the streamed response directly
1353 | return new Response(response.body, {
1354 | status: response.status,
1355 | headers: {
1356 | "Content-Type": "text/event-stream",
1357 | "Cache-Control": "no-cache",
1358 | Connection: "keep-alive",
1359 | ...CORS,
1360 | },
1361 | });
1362 | } else {
1363 | // Non-streaming response
1364 | const responseText = await response.text();
1365 | return new Response(responseText, {
1366 | status: response.status,
1367 | headers: {
1368 | "Content-Type":
1369 | response.headers.get("content-type") || "text/plain",
1370 | ...CORS,
1371 | },
1372 | });
1373 | }
1374 | } catch (error) {
1375 | log(`Error proxying GET /mcp: ${error}`);
1376 | return new Response(`Proxy error: ${error.message}`, {
1377 | status: 502,
1378 | headers: CORS,
1379 | });
1380 | }
1381 | })
1382 | .post("/mcp", stytchBearerTokenAuthMiddleware, async (c) => {
1383 | log("MCP POST endpoint hit - Streamable HTTP transport");
1384 | const REMOTE_MCP_SERVER_URL =
1385 | c.env.REMOTE_MCP_SERVER_URL || "http://localhost:8000";
1386 |
1387 | // Extract and validate session ID
1388 | const rawSessionId = new URL(c.req.url).searchParams.get("session_id");
1389 | const sessionId = validateSessionId(rawSessionId);
1390 |
1391 | // Get the request body
1392 | const bodyText = await c.req.text();
1393 | log(`MCP POST request body: ${bodyText.substring(0, 200)}`);
1394 |
1395 | // Create new request for proxying
1396 | const newRequest = new Request(c.req.url, {
1397 | method: "POST",
1398 | headers: c.req.headers,
1399 | body: bodyText,
1400 | });
1401 |
1402 | // Use the updated proxyPost function that handles SSE properly
1403 | return proxyPost(newRequest, REMOTE_MCP_SERVER_URL, "/mcp", sessionId);
1404 | })
1405 |
1406 | // Default 404 response
1407 | .all(
1408 | "*",
1409 | () =>
1410 | new Response("Not Found", {
1411 | status: 404,
1412 | headers: CORS,
1413 | }),
1414 | );
1415 |
1416 | // Export the app as the main worker fetch handler
1417 | export default {
1418 | fetch: (request, env, ctx) => {
1419 | // Initialize DEBUG from environment variables
1420 | DEBUG = env.DEBUG === "true" || env.DEBUG === true;
1421 |
1422 | return app.fetch(request, env, ctx);
1423 | },
1424 | };
1425 |
```
--------------------------------------------------------------------------------
/src/biomcp/individual_tools.py:
--------------------------------------------------------------------------------
```python
1 | """Individual MCP tools for specific biomedical search and fetch operations.
2 |
3 | This module provides the original 9 individual tools that offer direct access
4 | to specific search and fetch functionality, complementing the unified tools.
5 | """
6 |
7 | import logging
8 | from typing import Annotated, Literal
9 |
10 | from pydantic import Field
11 |
12 | from biomcp.articles.fetch import _article_details
13 | from biomcp.articles.search import _article_searcher
14 | from biomcp.cbioportal_helper import (
15 | get_cbioportal_summary_for_genes,
16 | get_variant_cbioportal_summary,
17 | )
18 | from biomcp.core import ensure_list, mcp_app
19 | from biomcp.diseases.getter import _disease_details
20 | from biomcp.drugs.getter import _drug_details
21 | from biomcp.genes.getter import _gene_details
22 | from biomcp.metrics import track_performance
23 | from biomcp.oncokb_helper import get_oncokb_summary_for_genes
24 | from biomcp.trials.getter import (
25 | _trial_locations,
26 | _trial_outcomes,
27 | _trial_protocol,
28 | _trial_references,
29 | )
30 | from biomcp.trials.search import _trial_searcher
31 | from biomcp.variants.getter import _variant_details
32 | from biomcp.variants.search import _variant_searcher
33 |
34 | logger = logging.getLogger(__name__)
35 |
36 |
37 | # Article Tools
38 | @mcp_app.tool()
39 | @track_performance("biomcp.article_searcher")
40 | async def article_searcher(
41 | chemicals: Annotated[
42 | list[str] | str | None,
43 | Field(description="Chemical/drug names to search for"),
44 | ] = None,
45 | diseases: Annotated[
46 | list[str] | str | None,
47 | Field(description="Disease names to search for"),
48 | ] = None,
49 | genes: Annotated[
50 | list[str] | str | None,
51 | Field(description="Gene symbols to search for"),
52 | ] = None,
53 | keywords: Annotated[
54 | list[str] | str | None,
55 | Field(description="Free-text keywords to search for"),
56 | ] = None,
57 | variants: Annotated[
58 | list[str] | str | None,
59 | Field(
60 | description="Variant strings to search for (e.g., 'V600E', 'p.D277Y')"
61 | ),
62 | ] = None,
63 | include_preprints: Annotated[
64 | bool,
65 | Field(description="Include preprints from bioRxiv/medRxiv"),
66 | ] = True,
67 | include_cbioportal: Annotated[
68 | bool,
69 | Field(
70 | description="Include cBioPortal cancer genomics summary when searching by gene"
71 | ),
72 | ] = True,
73 | page: Annotated[
74 | int,
75 | Field(description="Page number (1-based)", ge=1),
76 | ] = 1,
77 | page_size: Annotated[
78 | int,
79 | Field(description="Results per page", ge=1, le=100),
80 | ] = 10,
81 | ) -> str:
82 | """Search PubMed/PubTator3 for research articles and preprints.
83 |
84 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
85 |
86 | Use this tool to find scientific literature ABOUT genes, variants, diseases, or chemicals.
87 | Results include articles from PubMed and optionally preprints from bioRxiv/medRxiv.
88 |
89 | Important: This searches for ARTICLES ABOUT these topics, not database records.
90 | For genetic variant database records, use variant_searcher instead.
91 |
92 | Example usage:
93 | - Find articles about BRAF mutations in melanoma
94 | - Search for papers on a specific drug's effects
95 | - Locate research on gene-disease associations
96 | """
97 | # Convert single values to lists
98 | chemicals = ensure_list(chemicals) if chemicals else None
99 | diseases = ensure_list(diseases) if diseases else None
100 | genes = ensure_list(genes) if genes else None
101 | keywords = ensure_list(keywords) if keywords else None
102 | variants = ensure_list(variants) if variants else None
103 |
104 | result = await _article_searcher(
105 | call_benefit="Direct article search for specific biomedical topics",
106 | chemicals=chemicals,
107 | diseases=diseases,
108 | genes=genes,
109 | keywords=keywords,
110 | variants=variants,
111 | include_preprints=include_preprints,
112 | include_cbioportal=include_cbioportal,
113 | )
114 |
115 | # Add cBioPortal summary if searching by gene
116 | if include_cbioportal and genes:
117 | request_params = {
118 | "keywords": keywords,
119 | "diseases": diseases,
120 | "chemicals": chemicals,
121 | "variants": variants,
122 | }
123 | cbioportal_summary = await get_cbioportal_summary_for_genes(
124 | genes, request_params
125 | )
126 | if cbioportal_summary:
127 | result = cbioportal_summary + "\n\n---\n\n" + result
128 |
129 | return result
130 |
131 |
132 | @mcp_app.tool()
133 | @track_performance("biomcp.article_getter")
134 | async def article_getter(
135 | pmid: Annotated[
136 | str,
137 | Field(
138 | description="Article identifier - either a PubMed ID (e.g., '38768446' or 'PMC11193658') or DOI (e.g., '10.1101/2024.01.20.23288905')"
139 | ),
140 | ],
141 | ) -> str:
142 | """Fetch detailed information for a specific article.
143 |
144 | Retrieves the full abstract and available text for an article by its identifier.
145 | Supports:
146 | - PubMed IDs (PMID) for published articles
147 | - PMC IDs for articles in PubMed Central
148 | - DOIs for preprints from Europe PMC
149 |
150 | Returns formatted text including:
151 | - Title
152 | - Abstract
153 | - Full text (when available from PMC for published articles)
154 | - Source information (PubMed or Europe PMC)
155 | """
156 | return await _article_details(
157 | call_benefit="Fetch detailed article information for analysis",
158 | pmid=pmid,
159 | )
160 |
161 |
162 | # Trial Tools
163 | @mcp_app.tool()
164 | @track_performance("biomcp.trial_searcher")
165 | async def trial_searcher(
166 | conditions: Annotated[
167 | list[str] | str | None,
168 | Field(description="Medical conditions to search for"),
169 | ] = None,
170 | interventions: Annotated[
171 | list[str] | str | None,
172 | Field(description="Treatment interventions to search for"),
173 | ] = None,
174 | other_terms: Annotated[
175 | list[str] | str | None,
176 | Field(description="Additional search terms"),
177 | ] = None,
178 | recruiting_status: Annotated[
179 | Literal["OPEN", "CLOSED", "ANY"] | None,
180 | Field(description="Filter by recruiting status"),
181 | ] = None,
182 | phase: Annotated[
183 | Literal[
184 | "EARLY_PHASE1",
185 | "PHASE1",
186 | "PHASE2",
187 | "PHASE3",
188 | "PHASE4",
189 | "NOT_APPLICABLE",
190 | ]
191 | | None,
192 | Field(description="Filter by clinical trial phase"),
193 | ] = None,
194 | location: Annotated[
195 | str | None,
196 | Field(description="Location term for geographic filtering"),
197 | ] = None,
198 | lat: Annotated[
199 | float | None,
200 | Field(
201 | description="Latitude for location-based search. AI agents should geocode city names before using.",
202 | ge=-90,
203 | le=90,
204 | ),
205 | ] = None,
206 | long: Annotated[
207 | float | None,
208 | Field(
209 | description="Longitude for location-based search. AI agents should geocode city names before using.",
210 | ge=-180,
211 | le=180,
212 | ),
213 | ] = None,
214 | distance: Annotated[
215 | int | None,
216 | Field(
217 | description="Distance in miles from lat/long coordinates",
218 | ge=1,
219 | ),
220 | ] = None,
221 | age_group: Annotated[
222 | Literal["CHILD", "ADULT", "OLDER_ADULT"] | None,
223 | Field(description="Filter by age group"),
224 | ] = None,
225 | sex: Annotated[
226 | Literal["FEMALE", "MALE", "ALL"] | None,
227 | Field(description="Filter by biological sex"),
228 | ] = None,
229 | healthy_volunteers: Annotated[
230 | Literal["YES", "NO"] | None,
231 | Field(description="Filter by healthy volunteer eligibility"),
232 | ] = None,
233 | study_type: Annotated[
234 | Literal["INTERVENTIONAL", "OBSERVATIONAL", "EXPANDED_ACCESS"] | None,
235 | Field(description="Filter by study type"),
236 | ] = None,
237 | funder_type: Annotated[
238 | Literal["NIH", "OTHER_GOV", "INDUSTRY", "OTHER"] | None,
239 | Field(description="Filter by funding source"),
240 | ] = None,
241 | page: Annotated[
242 | int,
243 | Field(description="Page number (1-based)", ge=1),
244 | ] = 1,
245 | page_size: Annotated[
246 | int,
247 | Field(description="Results per page", ge=1, le=100),
248 | ] = 10,
249 | ) -> str:
250 | """Search ClinicalTrials.gov for clinical studies.
251 |
252 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
253 |
254 | Comprehensive search tool for finding clinical trials based on multiple criteria.
255 | Supports filtering by conditions, interventions, location, phase, and eligibility.
256 |
257 | Location search notes:
258 | - Use either location term OR lat/long coordinates, not both
259 | - For city-based searches, AI agents should geocode to lat/long first
260 | - Distance parameter only works with lat/long coordinates
261 |
262 | Returns a formatted list of matching trials with key details.
263 | """
264 | # Validate location parameters
265 | if location and (lat is not None or long is not None):
266 | raise ValueError(
267 | "Use either location term OR lat/long coordinates, not both"
268 | )
269 |
270 | if (lat is not None and long is None) or (
271 | lat is None and long is not None
272 | ):
273 | raise ValueError(
274 | "Both latitude and longitude must be provided together"
275 | )
276 |
277 | if distance is not None and (lat is None or long is None):
278 | raise ValueError(
279 | "Distance parameter requires both latitude and longitude"
280 | )
281 |
282 | # Convert single values to lists
283 | conditions = ensure_list(conditions) if conditions else None
284 | interventions = ensure_list(interventions) if interventions else None
285 | other_terms = ensure_list(other_terms) if other_terms else None
286 |
287 | return await _trial_searcher(
288 | call_benefit="Direct clinical trial search for specific criteria",
289 | conditions=conditions,
290 | interventions=interventions,
291 | terms=other_terms,
292 | recruiting_status=recruiting_status,
293 | phase=phase,
294 | lat=lat,
295 | long=long,
296 | distance=distance,
297 | age_group=age_group,
298 | study_type=study_type,
299 | page_size=page_size,
300 | )
301 |
302 |
303 | @mcp_app.tool()
304 | @track_performance("biomcp.trial_getter")
305 | async def trial_getter(
306 | nct_id: Annotated[
307 | str,
308 | Field(description="NCT ID (e.g., 'NCT06524388')"),
309 | ],
310 | ) -> str:
311 | """Fetch comprehensive details for a specific clinical trial.
312 |
313 | Retrieves all available information for a clinical trial by its NCT ID.
314 | This includes protocol details, locations, outcomes, and references.
315 |
316 | For specific sections only, use the specialized getter tools:
317 | - trial_protocol_getter: Core protocol information
318 | - trial_locations_getter: Site locations and contacts
319 | - trial_outcomes_getter: Primary/secondary outcomes and results
320 | - trial_references_getter: Publications and references
321 | """
322 | results = []
323 |
324 | # Get all sections
325 | protocol = await _trial_protocol(
326 | call_benefit="Fetch comprehensive trial details for analysis",
327 | nct_id=nct_id,
328 | )
329 | if protocol:
330 | results.append(protocol)
331 |
332 | locations = await _trial_locations(
333 | call_benefit="Fetch comprehensive trial details for analysis",
334 | nct_id=nct_id,
335 | )
336 | if locations:
337 | results.append(locations)
338 |
339 | outcomes = await _trial_outcomes(
340 | call_benefit="Fetch comprehensive trial details for analysis",
341 | nct_id=nct_id,
342 | )
343 | if outcomes:
344 | results.append(outcomes)
345 |
346 | references = await _trial_references(
347 | call_benefit="Fetch comprehensive trial details for analysis",
348 | nct_id=nct_id,
349 | )
350 | if references:
351 | results.append(references)
352 |
353 | return (
354 | "\n\n".join(results)
355 | if results
356 | else f"No data found for trial {nct_id}"
357 | )
358 |
359 |
360 | @mcp_app.tool()
361 | @track_performance("biomcp.trial_protocol_getter")
362 | async def trial_protocol_getter(
363 | nct_id: Annotated[
364 | str,
365 | Field(description="NCT ID (e.g., 'NCT06524388')"),
366 | ],
367 | ) -> str:
368 | """Fetch core protocol information for a clinical trial.
369 |
370 | Retrieves essential protocol details including:
371 | - Official title and brief summary
372 | - Study status and sponsor information
373 | - Study design (type, phase, allocation, masking)
374 | - Eligibility criteria
375 | - Primary completion date
376 | """
377 | return await _trial_protocol(
378 | call_benefit="Fetch trial protocol information for eligibility assessment",
379 | nct_id=nct_id,
380 | )
381 |
382 |
383 | @mcp_app.tool()
384 | @track_performance("biomcp.trial_references_getter")
385 | async def trial_references_getter(
386 | nct_id: Annotated[
387 | str,
388 | Field(description="NCT ID (e.g., 'NCT06524388')"),
389 | ],
390 | ) -> str:
391 | """Fetch publications and references for a clinical trial.
392 |
393 | Retrieves all linked publications including:
394 | - Published results papers
395 | - Background literature
396 | - Protocol publications
397 | - Related analyses
398 |
399 | Includes PubMed IDs when available for easy cross-referencing.
400 | """
401 | return await _trial_references(
402 | call_benefit="Fetch trial publications and references for evidence review",
403 | nct_id=nct_id,
404 | )
405 |
406 |
407 | @mcp_app.tool()
408 | @track_performance("biomcp.trial_outcomes_getter")
409 | async def trial_outcomes_getter(
410 | nct_id: Annotated[
411 | str,
412 | Field(description="NCT ID (e.g., 'NCT06524388')"),
413 | ],
414 | ) -> str:
415 | """Fetch outcome measures and results for a clinical trial.
416 |
417 | Retrieves detailed outcome information including:
418 | - Primary outcome measures
419 | - Secondary outcome measures
420 | - Results data (if available)
421 | - Adverse events (if reported)
422 |
423 | Note: Results are only available for completed trials that have posted data.
424 | """
425 | return await _trial_outcomes(
426 | call_benefit="Fetch trial outcome measures and results for efficacy assessment",
427 | nct_id=nct_id,
428 | )
429 |
430 |
431 | @mcp_app.tool()
432 | @track_performance("biomcp.trial_locations_getter")
433 | async def trial_locations_getter(
434 | nct_id: Annotated[
435 | str,
436 | Field(description="NCT ID (e.g., 'NCT06524388')"),
437 | ],
438 | ) -> str:
439 | """Fetch contact and location details for a clinical trial.
440 |
441 | Retrieves all study locations including:
442 | - Facility names and addresses
443 | - Principal investigator information
444 | - Contact details (when recruiting)
445 | - Recruitment status by site
446 |
447 | Useful for finding trials near specific locations or contacting study teams.
448 | """
449 | return await _trial_locations(
450 | call_benefit="Fetch trial locations and contacts for enrollment information",
451 | nct_id=nct_id,
452 | )
453 |
454 |
455 | # Variant Tools
456 | @mcp_app.tool()
457 | @track_performance("biomcp.variant_searcher")
458 | async def variant_searcher(
459 | gene: Annotated[
460 | str | None,
461 | Field(description="Gene symbol (e.g., 'BRAF', 'TP53')"),
462 | ] = None,
463 | hgvs: Annotated[
464 | str | None,
465 | Field(description="HGVS notation (genomic, coding, or protein)"),
466 | ] = None,
467 | hgvsp: Annotated[
468 | str | None,
469 | Field(description="Protein change in HGVS format (e.g., 'p.V600E')"),
470 | ] = None,
471 | hgvsc: Annotated[
472 | str | None,
473 | Field(description="Coding sequence change (e.g., 'c.1799T>A')"),
474 | ] = None,
475 | rsid: Annotated[
476 | str | None,
477 | Field(description="dbSNP rsID (e.g., 'rs113488022')"),
478 | ] = None,
479 | region: Annotated[
480 | str | None,
481 | Field(description="Genomic region (e.g., 'chr7:140753336-140753337')"),
482 | ] = None,
483 | significance: Annotated[
484 | Literal[
485 | "pathogenic",
486 | "likely_pathogenic",
487 | "uncertain_significance",
488 | "likely_benign",
489 | "benign",
490 | "conflicting",
491 | ]
492 | | None,
493 | Field(description="Clinical significance filter"),
494 | ] = None,
495 | frequency_min: Annotated[
496 | float | None,
497 | Field(description="Minimum allele frequency", ge=0, le=1),
498 | ] = None,
499 | frequency_max: Annotated[
500 | float | None,
501 | Field(description="Maximum allele frequency", ge=0, le=1),
502 | ] = None,
503 | consequence: Annotated[
504 | str | None,
505 | Field(description="Variant consequence (e.g., 'missense_variant')"),
506 | ] = None,
507 | cadd_score_min: Annotated[
508 | float | None,
509 | Field(description="Minimum CADD score for pathogenicity"),
510 | ] = None,
511 | sift_prediction: Annotated[
512 | Literal["deleterious", "tolerated"] | None,
513 | Field(description="SIFT functional prediction"),
514 | ] = None,
515 | polyphen_prediction: Annotated[
516 | Literal["probably_damaging", "possibly_damaging", "benign"] | None,
517 | Field(description="PolyPhen-2 functional prediction"),
518 | ] = None,
519 | include_cbioportal: Annotated[
520 | bool,
521 | Field(
522 | description="Include cBioPortal cancer genomics summary when searching by gene"
523 | ),
524 | ] = True,
525 | include_oncokb: Annotated[
526 | bool,
527 | Field(
528 | description="Include OncoKB precision oncology summary when searching by gene"
529 | ),
530 | ] = True,
531 | page: Annotated[
532 | int,
533 | Field(description="Page number (1-based)", ge=1),
534 | ] = 1,
535 | page_size: Annotated[
536 | int,
537 | Field(description="Results per page", ge=1, le=100),
538 | ] = 10,
539 | ) -> str:
540 | """Search MyVariant.info for genetic variant DATABASE RECORDS.
541 |
542 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
543 |
544 | Important: This searches for variant DATABASE RECORDS (frequency, significance, etc.),
545 | NOT articles about variants. For articles about variants, use article_searcher.
546 |
547 | Searches the comprehensive variant database including:
548 | - Population frequencies (gnomAD, 1000 Genomes, etc.)
549 | - Clinical significance (ClinVar)
550 | - Functional predictions (SIFT, PolyPhen, CADD)
551 | - Gene and protein consequences
552 |
553 | Search by various identifiers or filter by clinical/functional criteria.
554 | """
555 | result = await _variant_searcher(
556 | call_benefit="Direct variant database search for genetic analysis",
557 | gene=gene,
558 | hgvsp=hgvsp,
559 | hgvsc=hgvsc,
560 | rsid=rsid,
561 | region=region,
562 | significance=significance,
563 | min_frequency=frequency_min,
564 | max_frequency=frequency_max,
565 | cadd=cadd_score_min,
566 | sift=sift_prediction,
567 | polyphen=polyphen_prediction,
568 | size=page_size,
569 | offset=(page - 1) * page_size if page > 1 else 0,
570 | )
571 |
572 | # Add cBioPortal summary if searching by gene
573 | if include_cbioportal and gene:
574 | cbioportal_summary = await get_variant_cbioportal_summary(gene)
575 | if cbioportal_summary:
576 | result = cbioportal_summary + "\n\n" + result
577 |
578 | # Add OncoKB summary if searching by gene
579 | if include_oncokb and gene:
580 | oncokb_summary = await get_oncokb_summary_for_genes([gene])
581 | if oncokb_summary:
582 | result = oncokb_summary + "\n\n" + result
583 |
584 | return result
585 |
586 |
587 | @mcp_app.tool()
588 | @track_performance("biomcp.variant_getter")
589 | async def variant_getter(
590 | variant_id: Annotated[
591 | str,
592 | Field(
593 | description="Variant ID (HGVS, rsID, or MyVariant ID like 'chr7:g.140753336A>T')"
594 | ),
595 | ],
596 | include_external: Annotated[
597 | bool,
598 | Field(
599 | description="Include external annotations (TCGA, 1000 Genomes, functional predictions)"
600 | ),
601 | ] = True,
602 | ) -> str:
603 | """Fetch comprehensive details for a specific genetic variant.
604 |
605 | Retrieves all available information for a variant including:
606 | - Gene location and consequences
607 | - Population frequencies across databases
608 | - Clinical significance from ClinVar
609 | - Functional predictions
610 | - External annotations (TCGA cancer data, conservation scores)
611 |
612 | Accepts various ID formats:
613 | - HGVS: NM_004333.4:c.1799T>A
614 | - rsID: rs113488022
615 | - MyVariant ID: chr7:g.140753336A>T
616 | """
617 | return await _variant_details(
618 | call_benefit="Fetch comprehensive variant annotations for interpretation",
619 | variant_id=variant_id,
620 | include_external=include_external,
621 | )
622 |
623 |
624 | @mcp_app.tool()
625 | @track_performance("biomcp.alphagenome_predictor")
626 | async def alphagenome_predictor(
627 | chromosome: Annotated[
628 | str,
629 | Field(description="Chromosome (e.g., 'chr7', 'chrX')"),
630 | ],
631 | position: Annotated[
632 | int,
633 | Field(description="1-based genomic position of the variant"),
634 | ],
635 | reference: Annotated[
636 | str,
637 | Field(description="Reference allele(s) (e.g., 'A', 'ATG')"),
638 | ],
639 | alternate: Annotated[
640 | str,
641 | Field(description="Alternate allele(s) (e.g., 'T', 'A')"),
642 | ],
643 | interval_size: Annotated[
644 | int,
645 | Field(
646 | description="Size of genomic interval to analyze in bp (max 1,000,000)",
647 | ge=2000,
648 | le=1000000,
649 | ),
650 | ] = 131072,
651 | tissue_types: Annotated[
652 | list[str] | str | None,
653 | Field(
654 | description="UBERON ontology terms for tissue-specific predictions (e.g., 'UBERON:0002367' for external ear)"
655 | ),
656 | ] = None,
657 | significance_threshold: Annotated[
658 | float,
659 | Field(
660 | description="Threshold for significant log2 fold changes (default: 0.5)",
661 | ge=0.0,
662 | le=5.0,
663 | ),
664 | ] = 0.5,
665 | api_key: Annotated[
666 | str | None,
667 | Field(
668 | description="AlphaGenome API key. Check if user mentioned 'my AlphaGenome API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
669 | ),
670 | ] = None,
671 | ) -> str:
672 | """Predict variant effects on gene regulation using Google DeepMind's AlphaGenome.
673 |
674 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your analysis strategy!
675 |
676 | AlphaGenome provides state-of-the-art predictions for how genetic variants
677 | affect gene regulation, including:
678 | - Gene expression changes (RNA-seq)
679 | - Chromatin accessibility impacts (ATAC-seq, DNase-seq)
680 | - Splicing alterations
681 | - Promoter activity changes (CAGE)
682 |
683 | This tool requires:
684 | 1. AlphaGenome to be installed (see error message for instructions)
685 | 2. An API key from https://deepmind.google.com/science/alphagenome
686 |
687 | API Key Options:
688 | - Provide directly via the api_key parameter
689 | - Or set ALPHAGENOME_API_KEY environment variable
690 |
691 | Example usage:
692 | - Predict regulatory effects of BRAF V600E mutation: chr7:140753336 A>T
693 | - Assess non-coding variant impact on gene expression
694 | - Evaluate promoter variants in specific tissues
695 |
696 | Note: This is an optional tool that enhances variant interpretation
697 | with AI predictions. Standard annotations remain available via variant_getter.
698 | """
699 | from biomcp.variants.alphagenome import predict_variant_effects
700 |
701 | # Convert tissue_types to list if needed
702 | tissue_types_list = ensure_list(tissue_types) if tissue_types else None
703 |
704 | # Call the prediction function
705 | return await predict_variant_effects(
706 | chromosome=chromosome,
707 | position=position,
708 | reference=reference,
709 | alternate=alternate,
710 | interval_size=interval_size,
711 | tissue_types=tissue_types_list,
712 | significance_threshold=significance_threshold,
713 | api_key=api_key,
714 | )
715 |
716 |
717 | # Gene Tools
718 | @mcp_app.tool()
719 | @track_performance("biomcp.gene_getter")
720 | async def gene_getter(
721 | gene_id_or_symbol: Annotated[
722 | str,
723 | Field(
724 | description="Gene symbol (e.g., 'TP53', 'BRAF') or Entrez ID (e.g., '7157')"
725 | ),
726 | ],
727 | ) -> str:
728 | """Get detailed gene information from MyGene.info.
729 |
730 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to understand your research goal!
731 |
732 | Provides real-time gene annotations including:
733 | - Official gene name and symbol
734 | - Gene summary/description
735 | - Aliases and alternative names
736 | - Gene type (protein-coding, etc.)
737 | - Links to external databases
738 |
739 | This tool fetches CURRENT gene information from MyGene.info, ensuring
740 | you always have the latest annotations and nomenclature.
741 |
742 | Example usage:
743 | - Get information about TP53 tumor suppressor
744 | - Look up BRAF kinase gene details
745 | - Find the official name for a gene by its alias
746 |
747 | Note: For genetic variants, use variant_searcher. For articles about genes, use article_searcher.
748 | """
749 | return await _gene_details(
750 | call_benefit="Get up-to-date gene annotations and information",
751 | gene_id_or_symbol=gene_id_or_symbol,
752 | )
753 |
754 |
755 | # Disease Tools
756 | @mcp_app.tool()
757 | @track_performance("biomcp.disease_getter")
758 | async def disease_getter(
759 | disease_id_or_name: Annotated[
760 | str,
761 | Field(
762 | description="Disease name (e.g., 'melanoma', 'lung cancer') or ontology ID (e.g., 'MONDO:0016575', 'DOID:1909')"
763 | ),
764 | ],
765 | ) -> str:
766 | """Get detailed disease information from MyDisease.info.
767 |
768 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to understand your research goal!
769 |
770 | Provides real-time disease annotations including:
771 | - Official disease name and definition
772 | - Disease synonyms and alternative names
773 | - Ontology mappings (MONDO, DOID, OMIM, etc.)
774 | - Associated phenotypes
775 | - Links to disease databases
776 |
777 | This tool fetches CURRENT disease information from MyDisease.info, ensuring
778 | you always have the latest ontology mappings and definitions.
779 |
780 | Example usage:
781 | - Get the definition of GIST (Gastrointestinal Stromal Tumor)
782 | - Look up synonyms for melanoma
783 | - Find the MONDO ID for a disease by name
784 |
785 | Note: For clinical trials about diseases, use trial_searcher. For articles about diseases, use article_searcher.
786 | """
787 | return await _disease_details(
788 | call_benefit="Get up-to-date disease definitions and ontology information",
789 | disease_id_or_name=disease_id_or_name,
790 | )
791 |
792 |
793 | @mcp_app.tool()
794 | @track_performance("biomcp.drug_getter")
795 | async def drug_getter(
796 | drug_id_or_name: Annotated[
797 | str,
798 | Field(
799 | description="Drug name (e.g., 'aspirin', 'imatinib') or ID (e.g., 'DB00945', 'CHEMBL941')"
800 | ),
801 | ],
802 | ) -> str:
803 | """Get detailed drug/chemical information from MyChem.info.
804 |
805 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to understand your research goal!
806 |
807 | This tool provides comprehensive drug information including:
808 | - Chemical properties (formula, InChIKey)
809 | - Drug identifiers (DrugBank, ChEMBL, PubChem)
810 | - Trade names and brand names
811 | - Clinical indications
812 | - Mechanism of action
813 | - Pharmacology details
814 | - Links to drug databases
815 |
816 | This tool fetches CURRENT drug information from MyChem.info, part of the
817 | BioThings suite, ensuring you always have the latest drug data.
818 |
819 | Example usage:
820 | - Get information about imatinib (Gleevec)
821 | - Look up details for DrugBank ID DB00619
822 | - Find the mechanism of action for pembrolizumab
823 |
824 | Note: For clinical trials about drugs, use trial_searcher. For articles about drugs, use article_searcher.
825 | """
826 | return await _drug_details(drug_id_or_name)
827 |
828 |
829 | # NCI-Specific Tools
830 | @mcp_app.tool()
831 | @track_performance("biomcp.nci_organization_searcher")
832 | async def nci_organization_searcher(
833 | name: Annotated[
834 | str | None,
835 | Field(
836 | description="Organization name to search for (partial match supported)"
837 | ),
838 | ] = None,
839 | organization_type: Annotated[
840 | str | None,
841 | Field(
842 | description="Type of organization (e.g., 'Academic', 'Industry', 'Government')"
843 | ),
844 | ] = None,
845 | city: Annotated[
846 | str | None,
847 | Field(
848 | description="City where organization is located. IMPORTANT: Always use with state to avoid API errors"
849 | ),
850 | ] = None,
851 | state: Annotated[
852 | str | None,
853 | Field(
854 | description="State/province code (e.g., 'CA', 'NY'). IMPORTANT: Always use with city to avoid API errors"
855 | ),
856 | ] = None,
857 | api_key: Annotated[
858 | str | None,
859 | Field(
860 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
861 | ),
862 | ] = None,
863 | page: Annotated[
864 | int,
865 | Field(description="Page number (1-based)", ge=1),
866 | ] = 1,
867 | page_size: Annotated[
868 | int,
869 | Field(description="Results per page", ge=1, le=100),
870 | ] = 20,
871 | ) -> str:
872 | """Search for organizations in the NCI Clinical Trials database.
873 |
874 | Searches the National Cancer Institute's curated database of organizations
875 | involved in cancer clinical trials. This includes:
876 | - Academic medical centers
877 | - Community hospitals
878 | - Industry sponsors
879 | - Government facilities
880 | - Research networks
881 |
882 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
883 |
884 | IMPORTANT: To avoid API errors, always use city AND state together when searching by location.
885 | The NCI API has limitations on broad searches.
886 |
887 | Example usage:
888 | - Find cancer centers in Boston, MA (city AND state)
889 | - Search for "MD Anderson" in Houston, TX
890 | - List academic organizations in Cleveland, OH
891 | - Search by organization name alone (without location)
892 | """
893 | from biomcp.integrations.cts_api import CTSAPIError
894 | from biomcp.organizations import search_organizations
895 | from biomcp.organizations.search import format_organization_results
896 |
897 | try:
898 | results = await search_organizations(
899 | name=name,
900 | org_type=organization_type,
901 | city=city,
902 | state=state,
903 | page_size=page_size,
904 | page=page,
905 | api_key=api_key,
906 | )
907 | return format_organization_results(results)
908 | except CTSAPIError as e:
909 | # Check for Elasticsearch bucket limit error
910 | error_msg = str(e)
911 | if "too_many_buckets_exception" in error_msg or "75000" in error_msg:
912 | return (
913 | "⚠️ **Search Too Broad**\n\n"
914 | "The NCI API cannot process this search because it returns too many results.\n\n"
915 | "**To fix this, try:**\n"
916 | "1. **Always use city AND state together** for location searches\n"
917 | "2. Add an organization name (even partial) to narrow results\n"
918 | "3. Use multiple filters together (name + location, or name + type)\n\n"
919 | "**Examples that work:**\n"
920 | "- `nci_organization_searcher(city='Cleveland', state='OH')`\n"
921 | "- `nci_organization_searcher(name='Cleveland Clinic')`\n"
922 | "- `nci_organization_searcher(name='cancer', city='Boston', state='MA')`\n"
923 | "- `nci_organization_searcher(organization_type='Academic', city='Houston', state='TX')`"
924 | )
925 | raise
926 |
927 |
928 | @mcp_app.tool()
929 | @track_performance("biomcp.nci_organization_getter")
930 | async def nci_organization_getter(
931 | organization_id: Annotated[
932 | str,
933 | Field(description="NCI organization ID (e.g., 'NCI-2011-03337')"),
934 | ],
935 | api_key: Annotated[
936 | str | None,
937 | Field(
938 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
939 | ),
940 | ] = None,
941 | ) -> str:
942 | """Get detailed information about a specific organization from NCI.
943 |
944 | Retrieves comprehensive details about an organization including:
945 | - Full name and aliases
946 | - Address and contact information
947 | - Organization type and role
948 | - Associated clinical trials
949 | - Research focus areas
950 |
951 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
952 |
953 | Example usage:
954 | - Get details about a specific cancer center
955 | - Find contact information for trial sponsors
956 | - View organization's trial portfolio
957 | """
958 | from biomcp.organizations import get_organization
959 | from biomcp.organizations.getter import format_organization_details
960 |
961 | org_data = await get_organization(
962 | org_id=organization_id,
963 | api_key=api_key,
964 | )
965 |
966 | return format_organization_details(org_data)
967 |
968 |
969 | @mcp_app.tool()
970 | @track_performance("biomcp.nci_intervention_searcher")
971 | async def nci_intervention_searcher(
972 | name: Annotated[
973 | str | None,
974 | Field(
975 | description="Intervention name to search for (e.g., 'pembrolizumab')"
976 | ),
977 | ] = None,
978 | intervention_type: Annotated[
979 | str | None,
980 | Field(
981 | description="Type of intervention: 'Drug', 'Device', 'Biological', 'Procedure', 'Radiation', 'Behavioral', 'Genetic', 'Dietary', 'Other'"
982 | ),
983 | ] = None,
984 | synonyms: Annotated[
985 | bool,
986 | Field(description="Include synonym matches in search"),
987 | ] = True,
988 | api_key: Annotated[
989 | str | None,
990 | Field(
991 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
992 | ),
993 | ] = None,
994 | page: Annotated[
995 | int,
996 | Field(description="Page number (1-based)", ge=1),
997 | ] = 1,
998 | page_size: Annotated[
999 | int | None,
1000 | Field(
1001 | description="Results per page. If not specified, returns all matching results.",
1002 | ge=1,
1003 | le=100,
1004 | ),
1005 | ] = None,
1006 | ) -> str:
1007 | """Search for interventions in the NCI Clinical Trials database.
1008 |
1009 | Searches the National Cancer Institute's curated database of interventions
1010 | used in cancer clinical trials. This includes:
1011 | - FDA-approved drugs
1012 | - Investigational agents
1013 | - Medical devices
1014 | - Surgical procedures
1015 | - Radiation therapies
1016 | - Behavioral interventions
1017 |
1018 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
1019 |
1020 | Example usage:
1021 | - Find all trials using pembrolizumab
1022 | - Search for CAR-T cell therapies
1023 | - List radiation therapy protocols
1024 | - Find dietary interventions
1025 | """
1026 | from biomcp.integrations.cts_api import CTSAPIError
1027 | from biomcp.interventions import search_interventions
1028 | from biomcp.interventions.search import format_intervention_results
1029 |
1030 | try:
1031 | results = await search_interventions(
1032 | name=name,
1033 | intervention_type=intervention_type,
1034 | synonyms=synonyms,
1035 | page_size=page_size,
1036 | page=page,
1037 | api_key=api_key,
1038 | )
1039 | return format_intervention_results(results)
1040 | except CTSAPIError as e:
1041 | # Check for Elasticsearch bucket limit error
1042 | error_msg = str(e)
1043 | if "too_many_buckets_exception" in error_msg or "75000" in error_msg:
1044 | return (
1045 | "⚠️ **Search Too Broad**\n\n"
1046 | "The NCI API cannot process this search because it returns too many results.\n\n"
1047 | "**Try adding more specific filters:**\n"
1048 | "- Add an intervention name (even partial)\n"
1049 | "- Specify an intervention type (e.g., 'Drug', 'Device')\n"
1050 | "- Search for a specific drug or therapy name\n\n"
1051 | "**Example searches that work better:**\n"
1052 | "- Search for 'pembrolizumab' instead of all drugs\n"
1053 | "- Search for 'CAR-T' to find CAR-T cell therapies\n"
1054 | "- Filter by type: Drug, Device, Procedure, etc."
1055 | )
1056 | raise
1057 |
1058 |
1059 | @mcp_app.tool()
1060 | @track_performance("biomcp.nci_intervention_getter")
1061 | async def nci_intervention_getter(
1062 | intervention_id: Annotated[
1063 | str,
1064 | Field(description="NCI intervention ID (e.g., 'INT123456')"),
1065 | ],
1066 | api_key: Annotated[
1067 | str | None,
1068 | Field(
1069 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
1070 | ),
1071 | ] = None,
1072 | ) -> str:
1073 | """Get detailed information about a specific intervention from NCI.
1074 |
1075 | Retrieves comprehensive details about an intervention including:
1076 | - Full name and synonyms
1077 | - Intervention type and category
1078 | - Mechanism of action (for drugs)
1079 | - FDA approval status
1080 | - Associated clinical trials
1081 | - Combination therapies
1082 |
1083 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
1084 |
1085 | Example usage:
1086 | - Get details about a specific drug
1087 | - Find all trials using a device
1088 | - View combination therapy protocols
1089 | """
1090 | from biomcp.interventions import get_intervention
1091 | from biomcp.interventions.getter import format_intervention_details
1092 |
1093 | intervention_data = await get_intervention(
1094 | intervention_id=intervention_id,
1095 | api_key=api_key,
1096 | )
1097 |
1098 | return format_intervention_details(intervention_data)
1099 |
1100 |
1101 | # Biomarker Tools
1102 | @mcp_app.tool()
1103 | @track_performance("biomcp.nci_biomarker_searcher")
1104 | async def nci_biomarker_searcher(
1105 | name: Annotated[
1106 | str | None,
1107 | Field(
1108 | description="Biomarker name to search for (e.g., 'PD-L1', 'EGFR mutation')"
1109 | ),
1110 | ] = None,
1111 | biomarker_type: Annotated[
1112 | str | None,
1113 | Field(description="Type of biomarker ('reference_gene' or 'branch')"),
1114 | ] = None,
1115 | api_key: Annotated[
1116 | str | None,
1117 | Field(
1118 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
1119 | ),
1120 | ] = None,
1121 | page: Annotated[
1122 | int,
1123 | Field(description="Page number (1-based)", ge=1),
1124 | ] = 1,
1125 | page_size: Annotated[
1126 | int,
1127 | Field(description="Results per page", ge=1, le=100),
1128 | ] = 20,
1129 | ) -> str:
1130 | """Search for biomarkers in the NCI Clinical Trials database.
1131 |
1132 | Searches for biomarkers used in clinical trial eligibility criteria.
1133 | This is essential for precision medicine trials that select patients
1134 | based on specific biomarker characteristics.
1135 |
1136 | Biomarker examples:
1137 | - Gene mutations (e.g., BRAF V600E, EGFR T790M)
1138 | - Protein expression (e.g., PD-L1 ≥ 50%, HER2 positive)
1139 | - Gene fusions (e.g., ALK fusion, ROS1 fusion)
1140 | - Other molecular markers (e.g., MSI-H, TMB-high)
1141 |
1142 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
1143 |
1144 | Note: Biomarker data availability may be limited in CTRP.
1145 | Results focus on biomarkers used in trial eligibility criteria.
1146 |
1147 | Example usage:
1148 | - Search for PD-L1 expression biomarkers
1149 | - Find trials requiring EGFR mutations
1150 | - Look up biomarkers tested by NGS
1151 | - Search for HER2 expression markers
1152 | """
1153 | from biomcp.biomarkers import search_biomarkers
1154 | from biomcp.biomarkers.search import format_biomarker_results
1155 | from biomcp.integrations.cts_api import CTSAPIError
1156 |
1157 | try:
1158 | results = await search_biomarkers(
1159 | name=name,
1160 | biomarker_type=biomarker_type,
1161 | page_size=page_size,
1162 | page=page,
1163 | api_key=api_key,
1164 | )
1165 | return format_biomarker_results(results)
1166 | except CTSAPIError as e:
1167 | # Check for Elasticsearch bucket limit error
1168 | error_msg = str(e)
1169 | if "too_many_buckets_exception" in error_msg or "75000" in error_msg:
1170 | return (
1171 | "⚠️ **Search Too Broad**\n\n"
1172 | "The NCI API cannot process this search because it returns too many results.\n\n"
1173 | "**Try adding more specific filters:**\n"
1174 | "- Add a biomarker name (even partial)\n"
1175 | "- Specify a gene symbol\n"
1176 | "- Add an assay type (e.g., 'IHC', 'NGS')\n\n"
1177 | "**Example searches that work:**\n"
1178 | "- `nci_biomarker_searcher(name='PD-L1')`\n"
1179 | "- `nci_biomarker_searcher(gene='EGFR', biomarker_type='mutation')`\n"
1180 | "- `nci_biomarker_searcher(assay_type='IHC')`"
1181 | )
1182 | raise
1183 |
1184 |
1185 | # NCI Disease Tools
1186 | @mcp_app.tool()
1187 | @track_performance("biomcp.nci_disease_searcher")
1188 | async def nci_disease_searcher(
1189 | name: Annotated[
1190 | str | None,
1191 | Field(description="Disease name to search for (partial match)"),
1192 | ] = None,
1193 | include_synonyms: Annotated[
1194 | bool,
1195 | Field(description="Include synonym matches in search"),
1196 | ] = True,
1197 | category: Annotated[
1198 | str | None,
1199 | Field(description="Disease category/type filter"),
1200 | ] = None,
1201 | api_key: Annotated[
1202 | str | None,
1203 | Field(
1204 | description="NCI API key. Check if user mentioned 'my NCI API key is...' in their message. If not provided here and no env var is set, user will be prompted to provide one."
1205 | ),
1206 | ] = None,
1207 | page: Annotated[
1208 | int,
1209 | Field(description="Page number (1-based)", ge=1),
1210 | ] = 1,
1211 | page_size: Annotated[
1212 | int,
1213 | Field(description="Results per page", ge=1, le=100),
1214 | ] = 20,
1215 | ) -> str:
1216 | """Search NCI's controlled vocabulary of cancer conditions.
1217 |
1218 | Searches the National Cancer Institute's curated database of cancer
1219 | conditions and diseases used in clinical trials. This is different from
1220 | the general disease_getter tool which uses MyDisease.info.
1221 |
1222 | NCI's disease vocabulary provides:
1223 | - Official cancer terminology used in trials
1224 | - Disease synonyms and alternative names
1225 | - Hierarchical disease classifications
1226 | - Standardized disease codes for trial matching
1227 |
1228 | Requires NCI API key from: https://clinicaltrialsapi.cancer.gov/
1229 |
1230 | Example usage:
1231 | - Search for specific cancer types (e.g., "melanoma")
1232 | - Find all lung cancer subtypes
1233 | - Look up official names for disease synonyms
1234 | - Get standardized disease terms for trial searches
1235 |
1236 | Note: This is specifically for NCI's cancer disease vocabulary.
1237 | For general disease information, use the disease_getter tool.
1238 | """
1239 | from biomcp.diseases import search_diseases
1240 | from biomcp.diseases.search import format_disease_results
1241 | from biomcp.integrations.cts_api import CTSAPIError
1242 |
1243 | try:
1244 | results = await search_diseases(
1245 | name=name,
1246 | include_synonyms=include_synonyms,
1247 | category=category,
1248 | page_size=page_size,
1249 | page=page,
1250 | api_key=api_key,
1251 | )
1252 | return format_disease_results(results)
1253 | except CTSAPIError as e:
1254 | # Check for Elasticsearch bucket limit error
1255 | error_msg = str(e)
1256 | if "too_many_buckets_exception" in error_msg or "75000" in error_msg:
1257 | return (
1258 | "⚠️ **Search Too Broad**\n\n"
1259 | "The NCI API cannot process this search because it returns too many results.\n\n"
1260 | "**Try adding more specific filters:**\n"
1261 | "- Add a disease name (even partial)\n"
1262 | "- Specify a disease category\n"
1263 | "- Use more specific search terms\n\n"
1264 | "**Example searches that work:**\n"
1265 | "- `nci_disease_searcher(name='melanoma')`\n"
1266 | "- `nci_disease_searcher(name='lung', category='maintype')`\n"
1267 | "- `nci_disease_searcher(name='NSCLC')`"
1268 | )
1269 | raise
1270 |
1271 |
1272 | # OpenFDA Tools
1273 | @mcp_app.tool()
1274 | @track_performance("biomcp.openfda_adverse_searcher")
1275 | async def openfda_adverse_searcher(
1276 | drug: Annotated[
1277 | str | None,
1278 | Field(description="Drug name to search for adverse events"),
1279 | ] = None,
1280 | reaction: Annotated[
1281 | str | None,
1282 | Field(description="Adverse reaction term to search for"),
1283 | ] = None,
1284 | serious: Annotated[
1285 | bool | None,
1286 | Field(description="Filter for serious events only"),
1287 | ] = None,
1288 | limit: Annotated[
1289 | int,
1290 | Field(description="Maximum number of results", ge=1, le=100),
1291 | ] = 25,
1292 | page: Annotated[
1293 | int,
1294 | Field(description="Page number (1-based)", ge=1),
1295 | ] = 1,
1296 | api_key: Annotated[
1297 | str | None,
1298 | Field(
1299 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1300 | ),
1301 | ] = None,
1302 | ) -> str:
1303 | """Search FDA adverse event reports (FAERS) for drug safety information.
1304 |
1305 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1306 |
1307 | Searches FDA's Adverse Event Reporting System for:
1308 | - Drug side effects and adverse reactions
1309 | - Serious event reports (death, hospitalization, disability)
1310 | - Safety signal patterns across patient populations
1311 |
1312 | Note: These reports do not establish causation - they are voluntary reports
1313 | that may contain incomplete or unverified information.
1314 | """
1315 | from biomcp.openfda import search_adverse_events
1316 |
1317 | skip = (page - 1) * limit
1318 | return await search_adverse_events(
1319 | drug=drug,
1320 | reaction=reaction,
1321 | serious=serious,
1322 | limit=limit,
1323 | skip=skip,
1324 | api_key=api_key,
1325 | )
1326 |
1327 |
1328 | @mcp_app.tool()
1329 | @track_performance("biomcp.openfda_adverse_getter")
1330 | async def openfda_adverse_getter(
1331 | report_id: Annotated[
1332 | str,
1333 | Field(description="Safety report ID"),
1334 | ],
1335 | api_key: Annotated[
1336 | str | None,
1337 | Field(
1338 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1339 | ),
1340 | ] = None,
1341 | ) -> str:
1342 | """Get detailed information for a specific FDA adverse event report.
1343 |
1344 | Retrieves complete details including:
1345 | - Patient demographics and medical history
1346 | - All drugs involved and dosages
1347 | - Complete list of adverse reactions
1348 | - Event narrative and outcomes
1349 | - Reporter information
1350 | """
1351 | from biomcp.openfda import get_adverse_event
1352 |
1353 | return await get_adverse_event(report_id, api_key=api_key)
1354 |
1355 |
1356 | @mcp_app.tool()
1357 | @track_performance("biomcp.openfda_label_searcher")
1358 | async def openfda_label_searcher(
1359 | name: Annotated[
1360 | str | None,
1361 | Field(description="Drug name to search for"),
1362 | ] = None,
1363 | indication: Annotated[
1364 | str | None,
1365 | Field(description="Search for drugs indicated for this condition"),
1366 | ] = None,
1367 | boxed_warning: Annotated[
1368 | bool,
1369 | Field(description="Filter for drugs with boxed warnings"),
1370 | ] = False,
1371 | section: Annotated[
1372 | str | None,
1373 | Field(
1374 | description="Specific label section (e.g., 'contraindications', 'warnings')"
1375 | ),
1376 | ] = None,
1377 | limit: Annotated[
1378 | int,
1379 | Field(description="Maximum number of results", ge=1, le=100),
1380 | ] = 25,
1381 | page: Annotated[
1382 | int,
1383 | Field(description="Page number (1-based)", ge=1),
1384 | ] = 1,
1385 | api_key: Annotated[
1386 | str | None,
1387 | Field(
1388 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1389 | ),
1390 | ] = None,
1391 | ) -> str:
1392 | """Search FDA drug product labels (SPL) for prescribing information.
1393 |
1394 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1395 |
1396 | Searches official FDA drug labels for:
1397 | - Approved indications and usage
1398 | - Dosage and administration guidelines
1399 | - Contraindications and warnings
1400 | - Drug interactions and adverse reactions
1401 | - Special population considerations
1402 |
1403 | Label sections include: indications, dosage, contraindications, warnings,
1404 | adverse, interactions, pregnancy, pediatric, geriatric, overdose
1405 | """
1406 | from biomcp.openfda import search_drug_labels
1407 |
1408 | skip = (page - 1) * limit
1409 | return await search_drug_labels(
1410 | name=name,
1411 | indication=indication,
1412 | boxed_warning=boxed_warning,
1413 | section=section,
1414 | limit=limit,
1415 | skip=skip,
1416 | api_key=api_key,
1417 | )
1418 |
1419 |
1420 | @mcp_app.tool()
1421 | @track_performance("biomcp.openfda_label_getter")
1422 | async def openfda_label_getter(
1423 | set_id: Annotated[
1424 | str,
1425 | Field(description="Label set ID"),
1426 | ],
1427 | sections: Annotated[
1428 | list[str] | None,
1429 | Field(
1430 | description="Specific sections to retrieve (default: key sections)"
1431 | ),
1432 | ] = None,
1433 | api_key: Annotated[
1434 | str | None,
1435 | Field(
1436 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1437 | ),
1438 | ] = None,
1439 | ) -> str:
1440 | """Get complete FDA drug label information by set ID.
1441 |
1442 | Retrieves the full prescribing information including:
1443 | - Complete indications and usage text
1444 | - Detailed dosing instructions
1445 | - All warnings and precautions
1446 | - Clinical pharmacology and studies
1447 | - Manufacturing and storage information
1448 |
1449 | Specify sections to retrieve specific parts, or leave empty for default key sections.
1450 | """
1451 | from biomcp.openfda import get_drug_label
1452 |
1453 | return await get_drug_label(set_id, sections, api_key=api_key)
1454 |
1455 |
1456 | @mcp_app.tool()
1457 | @track_performance("biomcp.openfda_device_searcher")
1458 | async def openfda_device_searcher(
1459 | device: Annotated[
1460 | str | None,
1461 | Field(description="Device name to search for"),
1462 | ] = None,
1463 | manufacturer: Annotated[
1464 | str | None,
1465 | Field(description="Manufacturer name"),
1466 | ] = None,
1467 | problem: Annotated[
1468 | str | None,
1469 | Field(description="Device problem description"),
1470 | ] = None,
1471 | product_code: Annotated[
1472 | str | None,
1473 | Field(description="FDA product code"),
1474 | ] = None,
1475 | genomics_only: Annotated[
1476 | bool,
1477 | Field(description="Filter to genomic/diagnostic devices only"),
1478 | ] = True,
1479 | limit: Annotated[
1480 | int,
1481 | Field(description="Maximum number of results", ge=1, le=100),
1482 | ] = 25,
1483 | page: Annotated[
1484 | int,
1485 | Field(description="Page number (1-based)", ge=1),
1486 | ] = 1,
1487 | api_key: Annotated[
1488 | str | None,
1489 | Field(
1490 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1491 | ),
1492 | ] = None,
1493 | ) -> str:
1494 | """Search FDA device adverse event reports (MAUDE) for medical device issues.
1495 |
1496 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1497 |
1498 | Searches FDA's device adverse event database for:
1499 | - Device malfunctions and failures
1500 | - Patient injuries related to devices
1501 | - Genomic test and diagnostic device issues
1502 |
1503 | By default, filters to genomic/diagnostic devices relevant to precision medicine.
1504 | Set genomics_only=False to search all medical devices.
1505 | """
1506 | from biomcp.openfda import search_device_events
1507 |
1508 | skip = (page - 1) * limit
1509 | return await search_device_events(
1510 | device=device,
1511 | manufacturer=manufacturer,
1512 | problem=problem,
1513 | product_code=product_code,
1514 | genomics_only=genomics_only,
1515 | limit=limit,
1516 | skip=skip,
1517 | api_key=api_key,
1518 | )
1519 |
1520 |
1521 | @mcp_app.tool()
1522 | @track_performance("biomcp.openfda_device_getter")
1523 | async def openfda_device_getter(
1524 | mdr_report_key: Annotated[
1525 | str,
1526 | Field(description="MDR report key"),
1527 | ],
1528 | api_key: Annotated[
1529 | str | None,
1530 | Field(
1531 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1532 | ),
1533 | ] = None,
1534 | ) -> str:
1535 | """Get detailed information for a specific FDA device event report.
1536 |
1537 | Retrieves complete device event details including:
1538 | - Device identification and specifications
1539 | - Complete event narrative
1540 | - Patient outcomes and impacts
1541 | - Manufacturer analysis and actions
1542 | - Remedial actions taken
1543 | """
1544 | from biomcp.openfda import get_device_event
1545 |
1546 | return await get_device_event(mdr_report_key, api_key=api_key)
1547 |
1548 |
1549 | @mcp_app.tool()
1550 | @track_performance("biomcp.openfda_approval_searcher")
1551 | async def openfda_approval_searcher(
1552 | drug: Annotated[
1553 | str | None,
1554 | Field(description="Drug name (brand or generic) to search for"),
1555 | ] = None,
1556 | application_number: Annotated[
1557 | str | None,
1558 | Field(description="NDA or BLA application number"),
1559 | ] = None,
1560 | approval_year: Annotated[
1561 | str | None,
1562 | Field(description="Year of approval (YYYY format)"),
1563 | ] = None,
1564 | limit: Annotated[
1565 | int,
1566 | Field(description="Maximum number of results", ge=1, le=100),
1567 | ] = 25,
1568 | page: Annotated[
1569 | int,
1570 | Field(description="Page number (1-based)", ge=1),
1571 | ] = 1,
1572 | api_key: Annotated[
1573 | str | None,
1574 | Field(
1575 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1576 | ),
1577 | ] = None,
1578 | ) -> str:
1579 | """Search FDA drug approval records from Drugs@FDA database.
1580 |
1581 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1582 |
1583 | Returns information about:
1584 | - Application numbers and sponsors
1585 | - Brand and generic names
1586 | - Product formulations and strengths
1587 | - Marketing status and approval dates
1588 | - Submission history
1589 |
1590 | Useful for verifying if a drug is FDA-approved and when.
1591 | """
1592 | from biomcp.openfda import search_drug_approvals
1593 |
1594 | skip = (page - 1) * limit
1595 | return await search_drug_approvals(
1596 | drug=drug,
1597 | application_number=application_number,
1598 | approval_year=approval_year,
1599 | limit=limit,
1600 | skip=skip,
1601 | api_key=api_key,
1602 | )
1603 |
1604 |
1605 | @mcp_app.tool()
1606 | @track_performance("biomcp.openfda_approval_getter")
1607 | async def openfda_approval_getter(
1608 | application_number: Annotated[
1609 | str,
1610 | Field(description="NDA or BLA application number"),
1611 | ],
1612 | api_key: Annotated[
1613 | str | None,
1614 | Field(
1615 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1616 | ),
1617 | ] = None,
1618 | ) -> str:
1619 | """Get detailed FDA drug approval information for a specific application.
1620 |
1621 | Returns comprehensive approval details including:
1622 | - Full product list with dosage forms and strengths
1623 | - Complete submission history
1624 | - Marketing status timeline
1625 | - Therapeutic equivalence codes
1626 | - Pharmacologic class information
1627 | """
1628 | from biomcp.openfda import get_drug_approval
1629 |
1630 | return await get_drug_approval(application_number, api_key=api_key)
1631 |
1632 |
1633 | @mcp_app.tool()
1634 | @track_performance("biomcp.openfda_recall_searcher")
1635 | async def openfda_recall_searcher(
1636 | drug: Annotated[
1637 | str | None,
1638 | Field(description="Drug name to search for recalls"),
1639 | ] = None,
1640 | recall_class: Annotated[
1641 | str | None,
1642 | Field(
1643 | description="Recall classification (1=most serious, 2=moderate, 3=least serious)"
1644 | ),
1645 | ] = None,
1646 | status: Annotated[
1647 | str | None,
1648 | Field(description="Recall status (ongoing, completed, terminated)"),
1649 | ] = None,
1650 | reason: Annotated[
1651 | str | None,
1652 | Field(description="Search text in recall reason"),
1653 | ] = None,
1654 | since_date: Annotated[
1655 | str | None,
1656 | Field(description="Show recalls after this date (YYYYMMDD format)"),
1657 | ] = None,
1658 | limit: Annotated[
1659 | int,
1660 | Field(description="Maximum number of results", ge=1, le=100),
1661 | ] = 25,
1662 | page: Annotated[
1663 | int,
1664 | Field(description="Page number (1-based)", ge=1),
1665 | ] = 1,
1666 | api_key: Annotated[
1667 | str | None,
1668 | Field(
1669 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1670 | ),
1671 | ] = None,
1672 | ) -> str:
1673 | """Search FDA drug recall records from the Enforcement database.
1674 |
1675 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1676 |
1677 | Returns recall information including:
1678 | - Classification (Class I, II, or III)
1679 | - Recall reason and description
1680 | - Product identification
1681 | - Distribution information
1682 | - Recalling firm details
1683 | - Current status
1684 |
1685 | Class I = most serious (death/serious harm)
1686 | Class II = moderate (temporary/reversible harm)
1687 | Class III = least serious (unlikely to cause harm)
1688 | """
1689 | from biomcp.openfda import search_drug_recalls
1690 |
1691 | skip = (page - 1) * limit
1692 | return await search_drug_recalls(
1693 | drug=drug,
1694 | recall_class=recall_class,
1695 | status=status,
1696 | reason=reason,
1697 | since_date=since_date,
1698 | limit=limit,
1699 | skip=skip,
1700 | api_key=api_key,
1701 | )
1702 |
1703 |
1704 | @mcp_app.tool()
1705 | @track_performance("biomcp.openfda_recall_getter")
1706 | async def openfda_recall_getter(
1707 | recall_number: Annotated[
1708 | str,
1709 | Field(description="FDA recall number"),
1710 | ],
1711 | api_key: Annotated[
1712 | str | None,
1713 | Field(
1714 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1715 | ),
1716 | ] = None,
1717 | ) -> str:
1718 | """Get detailed FDA drug recall information for a specific recall.
1719 |
1720 | Returns complete recall details including:
1721 | - Full product description and code information
1722 | - Complete reason for recall
1723 | - Distribution pattern and locations
1724 | - Quantity of product recalled
1725 | - Firm information and actions taken
1726 | - Timeline of recall events
1727 | """
1728 | from biomcp.openfda import get_drug_recall
1729 |
1730 | return await get_drug_recall(recall_number, api_key=api_key)
1731 |
1732 |
1733 | @mcp_app.tool()
1734 | @track_performance("biomcp.openfda_shortage_searcher")
1735 | async def openfda_shortage_searcher(
1736 | drug: Annotated[
1737 | str | None,
1738 | Field(description="Drug name (generic or brand) to search"),
1739 | ] = None,
1740 | status: Annotated[
1741 | str | None,
1742 | Field(description="Shortage status (current or resolved)"),
1743 | ] = None,
1744 | therapeutic_category: Annotated[
1745 | str | None,
1746 | Field(
1747 | description="Therapeutic category (e.g., Oncology, Anti-infective)"
1748 | ),
1749 | ] = None,
1750 | limit: Annotated[
1751 | int,
1752 | Field(description="Maximum number of results", ge=1, le=100),
1753 | ] = 25,
1754 | page: Annotated[
1755 | int,
1756 | Field(description="Page number (1-based)", ge=1),
1757 | ] = 1,
1758 | api_key: Annotated[
1759 | str | None,
1760 | Field(
1761 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1762 | ),
1763 | ] = None,
1764 | ) -> str:
1765 | """Search FDA drug shortage records.
1766 |
1767 | ⚠️ PREREQUISITE: Use the 'think' tool FIRST to plan your research strategy!
1768 |
1769 | Returns shortage information including:
1770 | - Current shortage status
1771 | - Shortage start and resolution dates
1772 | - Reason for shortage
1773 | - Therapeutic category
1774 | - Manufacturer information
1775 | - Estimated resolution timeline
1776 |
1777 | Note: Shortage data is cached and updated periodically.
1778 | Check FDA.gov for most current information.
1779 | """
1780 | from biomcp.openfda import search_drug_shortages
1781 |
1782 | skip = (page - 1) * limit
1783 | return await search_drug_shortages(
1784 | drug=drug,
1785 | status=status,
1786 | therapeutic_category=therapeutic_category,
1787 | limit=limit,
1788 | skip=skip,
1789 | api_key=api_key,
1790 | )
1791 |
1792 |
1793 | @mcp_app.tool()
1794 | @track_performance("biomcp.openfda_shortage_getter")
1795 | async def openfda_shortage_getter(
1796 | drug: Annotated[
1797 | str,
1798 | Field(description="Drug name (generic or brand)"),
1799 | ],
1800 | api_key: Annotated[
1801 | str | None,
1802 | Field(
1803 | description="Optional OpenFDA API key (overrides OPENFDA_API_KEY env var)"
1804 | ),
1805 | ] = None,
1806 | ) -> str:
1807 | """Get detailed FDA drug shortage information for a specific drug.
1808 |
1809 | Returns comprehensive shortage details including:
1810 | - Complete timeline of shortage
1811 | - Detailed reason for shortage
1812 | - All affected manufacturers
1813 | - Alternative products if available
1814 | - Resolution status and estimates
1815 | - Additional notes and recommendations
1816 |
1817 | Data is updated periodically from FDA shortage database.
1818 | """
1819 | from biomcp.openfda import get_drug_shortage
1820 |
1821 | return await get_drug_shortage(drug, api_key=api_key)
1822 |
```