inou/mcp-bridge-node/index.js

203 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API = "https://inou.com";
const BRIDGE_VERSION = "1.6.1";
const accountId = process.env.INOU_API_TOKEN || process.argv.find(a => a.startsWith("--token="))?.slice(8);
if (!accountId) {
console.error("Missing INOU_API_TOKEN environment variable");
process.exit(1);
}
// Version check
let updateAvailable = null;
async function checkVersion() {
try {
const res = await fetch(`${API}/api/version`);
if (res.ok) {
const data = await res.json();
if (data.latest_bridge_version && data.latest_bridge_version !== BRIDGE_VERSION) {
updateAvailable = {
current: BRIDGE_VERSION,
latest: data.latest_bridge_version,
message: data.update_message,
url: data.download_url
};
}
}
} catch (e) {
// Ignore version check errors
}
}
// Token management (OAuth 2.0 refresh flow)
let accessToken = null;
let tokenExpires = 0;
let refreshTokenValue = accountId; // Initial refresh token from config
async function refreshToken() {
const res = await fetch(`${API}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: "inou-bridge",
refresh_token: refreshTokenValue
})
});
if (!res.ok) {
const error = await res.text();
throw new Error(`Token refresh failed: ${res.status} - ${error}`);
}
const data = await res.json();
accessToken = data.access_token;
refreshTokenValue = data.refresh_token; // OAuth rotates refresh tokens
tokenExpires = Date.now() + (data.expires_in * 1000) - 60000; // Refresh 1 min early
}
async function getToken() {
if (!accessToken || Date.now() > tokenExpires) {
await refreshToken();
}
return accessToken;
}
// API helper with auto-refresh
async function api(path, params = {}) {
const token = await getToken();
const url = new URL(path, API);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
});
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
// If unauthorized, try refreshing token once
if (res.status === 401) {
await refreshToken();
const newToken = await getToken();
res = await fetch(url, { headers: { Authorization: `Bearer ${newToken}` } });
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res;
}
// JSON response helper
async function json(path, params) {
const res = await api(path, params);
return { content: [{ type: "text", text: JSON.stringify(await res.json(), null, 2) }] };
}
// Image response helper
async function image(path, params, mimeType, label) {
const res = await api(path, params);
const buffer = await res.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
return {
content: [
{ type: "image", data: base64, mimeType },
{ type: "text", text: `${label} (${Math.round(buffer.byteLength / 1024)}KB)` }
]
};
}
// Create server
const server = new McpServer({ name: "inou-health", version: "1.6.0" });
// Tool annotations - all tools are read-only
const readOnly = { readOnlyHint: true };
// Tools
server.tool("list_dossiers", "List all patient dossiers accessible to this account.", {}, readOnly,
async () => {
const result = await json("/api/v1/dossiers");
if (updateAvailable) {
result.content.push({
type: "text",
text: `\n⚠️ UPDATE AVAILABLE: v${updateAvailable.latest}\n${updateAvailable.message}`
});
}
return result;
});
server.tool("list_studies", "List all imaging studies for a patient dossier.", {
dossier: z.string().describe("Dossier ID (16-char hex)")
}, readOnly, async ({ dossier }) => json(`/api/v1/dossiers/${dossier}/entries`, { category: "imaging" }));
server.tool("list_series", "List series for a study. Filter by description (AX, T1, FLAIR, etc).", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
study: z.string().describe("Study ID (16-char hex)"),
filter: z.string().optional().describe("Filter by description")
}, readOnly, async ({ dossier, study, filter }) =>
json(`/api/v1/dossiers/${dossier}/entries`, { parent: study, filter }));
server.tool("list_slices", "List slices for a series with position info.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
series: z.string().describe("Series ID (16-char hex)")
}, readOnly, async ({ dossier, series }) =>
json(`/api/v1/dossiers/${dossier}/entries`, { parent: series }));
server.tool("fetch_image", "Fetch slice image as base64 PNG. Optionally set window/level.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
slice: z.string().describe("Slice ID (16-char hex)"),
wc: z.number().optional().describe("Window center"),
ww: z.number().optional().describe("Window width")
}, readOnly, async ({ dossier, slice, wc, ww }) =>
image(`/image/${slice}`, { token: dossier, wc, ww }, "image/png", `Slice ${slice.slice(0, 8)}`));
server.tool("fetch_contact_sheet", "Fetch contact sheet (thumbnail grid) for NAVIGATION ONLY. Use to identify slices, then fetch at full resolution. NEVER diagnose from thumbnails.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
series: z.string().describe("Series ID (16-char hex)"),
wc: z.number().optional().describe("Window center (e.g., -500 for lung)"),
ww: z.number().optional().describe("Window width (e.g., 1500 for lung)")
}, readOnly, async ({ dossier, series, wc, ww }) =>
image(`/contact-sheet.webp/${series}`, { token: dossier, wc, ww }, "image/webp", `Contact sheet ${series.slice(0, 8)}`));
server.tool("list_lab_tests", "List all lab test names for a patient dossier.", {
dossier: z.string().describe("Dossier ID (16-char hex)")
}, readOnly, async ({ dossier }) => json("/api/labs/tests", { dossier }));
server.tool("get_lab_results", "Get lab results. Specify names, optional date range, optional latest flag.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
names: z.string().describe("Comma-separated test names"),
from: z.string().optional().describe("Start date YYYY-MM-DD"),
to: z.string().optional().describe("End date YYYY-MM-DD"),
latest: z.boolean().optional().describe("Most recent only")
}, readOnly, async ({ dossier, names, from, to, latest }) =>
json("/api/labs/results", { dossier, names, from, to, latest: latest ? "true" : undefined }));
server.tool("get_categories", "Get observation categories. Without type: top-level. With type=genome: genome categories.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
type: z.string().optional().describe("Observation type (e.g., genome)"),
category: z.string().optional().describe("Get subcategories within this category")
}, readOnly, async ({ dossier, type, category }) => json("/api/categories", { dossier, type, category }));
server.tool("query_genome", "Query genome variants by gene(s), category, or rsids. Returns matches with magnitude, summary, and categories.", {
dossier: z.string().describe("Dossier ID (16-char hex)"),
gene: z.string().optional().describe("Gene name(s), comma-separated (e.g., MTHFR or MTHFR,COMT,CYP2D6)"),
search: z.string().optional().describe("Search gene, subcategory, or summary"),
category: z.string().optional().describe("Filter by category"),
rsids: z.string().optional().describe("Comma-separated rsids"),
min_magnitude: z.number().optional().describe("Minimum magnitude"),
include_hidden: z.boolean().optional().describe("Include hidden categories and high-magnitude variants")
}, readOnly, async ({ dossier, gene, search, category, rsids, min_magnitude, include_hidden }) =>
json("/api/genome", { dossier, gene, search, category, rsids, min_magnitude, include_hidden: include_hidden ? "true" : undefined }));
server.tool("get_version", "Get bridge and server version info.", {}, readOnly,
async () => {
let text = `Bridge: ${BRIDGE_VERSION} (Node.js)\nServer: inou.com`;
if (updateAvailable) {
text += `\n\n⚠️ UPDATE AVAILABLE: ${updateAvailable.latest}\n${updateAvailable.message}`;
}
return { content: [{ type: "text", text }] };
});
// Run
await checkVersion(); // Check for updates on startup
const transport = new StdioServerTransport();
await server.connect(transport);