diff --git a/api/data_loader_pg.py b/api/data_loader_pg.py index 28e8653..09aaa1f 100644 --- a/api/data_loader_pg.py +++ b/api/data_loader_pg.py @@ -114,7 +114,11 @@ def _shape_run_record(row) -> Dict[str, Any]: rec = dict(row) for ts_field in ("run_date", "created_at"): if rec.get(ts_field): - rec[ts_field] = rec[ts_field].isoformat() + rec[ts_field] = ( + rec[ts_field].isoformat() + if hasattr(rec[ts_field], "isoformat") + else str(rec[ts_field]) + ) rec["features"] = { "Etch_AvgO2Flow": rec.pop("etch_avgo2flow", None), "Etch_Avg_Rf1_Pow": rec.pop("etch_avg_rf1_pow", None), @@ -518,7 +522,7 @@ def get_projects_list_pg(nanohub_user_id: Optional[str] = None, is_admin: bool = records = [] for row in rows: rec = dict(row) - if rec.get('created'): + if rec.get('created') and hasattr(rec['created'], "isoformat"): rec['created'] = rec['created'].isoformat() records.append(rec) diff --git a/api/db_mock.py b/api/db_mock.py index 1500ec8..8a24afc 100644 --- a/api/db_mock.py +++ b/api/db_mock.py @@ -20,7 +20,13 @@ def execute(self, query: str, params: Optional[Any] = None): logger.info(f"[DB-MOCK] Executing query: {query[:100]}...") # Extremely simple query router for the simulation - if "from projects" in query: + if "from equipment_metadata" in query: + self.results = self.data.get("equipment_metadata", []) + elif "from experiment_types" in query: + self.results = self.data.get("experiment_types", []) + elif "from process_definitions" in query: + self.results = self.data.get("process_definitions", []) + elif "from projects" in query: self.results = self.data.get("projects", []) elif "from project_members" in query: self.results = self.data.get("project_members", []) diff --git a/api/local_data/mock_pg_data.json b/api/local_data/mock_pg_data.json index 22ece6f..206bc8f 100644 --- a/api/local_data/mock_pg_data.json +++ b/api/local_data/mock_pg_data.json @@ -28,14 +28,119 @@ "role": "pi" } ], + "equipment_metadata": [ + { + "domain_id": "etcher", + "equipment_name": "PlasmaTherm ICP Etcher", + "equipment_type": "Etching", + "manufacturer": "PlasmaTherm", + "model": "ICP", + "location": "Birck Nanotechnology Center", + "serial_number": "DEMO-ETCHER", + "description": "Mock ICP etcher record for local Digital Twin testing.", + "source_type": "postgresql", + "connection_host": "localhost", + "connection_port": "5432", + "connection_database": "digital_twin", + "dt_mode": "full", + "outlier_method": "zscore", + "outlier_threshold": 3.0, + "owner_id": "ngholiza", + "owner_org": "Birck Nanotechnology Center", + "status": "approved", + "registered_at": "2026-04-18T20:00:00Z", + "updated_at": "2026-04-18T20:00:00Z", + "parameters_json": [ + {"id": "etch_avg_rf1_pow", "name": "RF1 Power", "unit": "W"}, + {"id": "etch_avgpres", "name": "Pressure", "unit": "mTorr"} + ], + "columns_json": [], + "features_json": [], + "primary_target_json": {"id": "avg_etch_rate", "name": "Average Etch Rate", "unit": "nm/min"}, + "secondary_target_json": {"id": "range_nm", "name": "Range", "unit": "nm"}, + "config_json": { + "outputs": [ + {"id": "avg_etch_rate", "name": "Average Etch Rate", "unit": "nm/min"}, + {"id": "range_nm", "name": "Range", "unit": "nm"} + ], + "operational_metadata": {"status": "available"} + } + } + ], + "experiment_types": [ + { + "id": "unit_icp_etch_demo", + "name": "ICP Etch Unit Experiment", + "description": "Mock unit experiment for ICP etching.", + "scientific_objective": "Tune etch rate while controlling range.", + "equipment_id": "etcher", + "type_parameters_json": [ + {"id": "etch_avg_rf1_pow", "name": "RF1 Power", "unit": "W"}, + {"id": "etch_avgpres", "name": "Pressure", "unit": "mTorr"} + ], + "outputs_json": [ + {"id": "avg_etch_rate", "name": "Average Etch Rate", "unit": "nm/min"}, + {"id": "range_nm", "name": "Range", "unit": "nm"} + ], + "additional_inputs_json": [], + "visibility": "shared", + "version": 1, + "pid": "DT-ET-DEMO", + "parent_id": "", + "forked_from": "", + "author_name": "N. Gholiza", + "author_email": "ngholiza@purdue.edu", + "primary_target": "avg_etch_rate", + "primary_objective": "maximize", + "secondary_target": "range_nm", + "secondary_objective": "minimize", + "owner_id": "ngholiza", + "owner_org": "Birck Nanotechnology Center", + "created_at": "2026-04-18T20:00:00Z", + "updated_at": "2026-04-18T20:00:00Z" + } + ], + "process_definitions": [ + { + "id": "process_icp_etch_demo", + "name": "ICP Etch Demo Process", + "description": "Mock reusable process for ICP etch optimization.", + "visibility": "shared", + "status": "active", + "version": 1, + "pid": "DT-PROC-DEMO", + "owner_id": "ngholiza", + "owner_org": "Birck Nanotechnology Center", + "created_at": "2026-04-18T20:00:00Z", + "updated_at": "2026-04-18T20:00:00Z", + "step_summary": [ + { + "id": "step_icp_etch_demo", + "process_id": "process_icp_etch_demo", + "type_id": "unit_icp_etch_demo", + "sequence_index": 1, + "upstream_type_id": "", + "handoff_type": "manual", + "handoff_description": "Run ICP etch and inspect output metrics.", + "output_mapping": {}, + "unit_experiment_name": "ICP Etch Unit Experiment", + "equipment_id": "etcher" + } + ] + } + ], "etcher_runs": [ { "idruns": 999999, + "run_id": 999999, "project_id": "project_demo", "lotname": "etch04/18/2026_SIM_01", + "lot_name": "etch04/18/2026_SIM_01", "run_date": "2026-04-19T00:45:57Z", + "created_at": "2026-04-19T00:45:57Z", "is_outlier": true, "is_calibration_recipe": false, + "is_calibration": false, "outlier_type": "simulation_mock", "avg_etch_rate": 195.5, "range_etch_rate": 3.0, @@ -44,7 +149,12 @@ "etch_avg_rf1_pow": 400.0, "etch_avg_rf2_pow": 50.0, "etch_avgpres": 25.0, - "etch_avgcf4flow": 50.0 + "etch_avgcf4flow": 50.0, + "project_name": "Simulation Demo Project", + "equipment_id": "etcher", + "equipment_name": "PlasmaTherm ICP Etcher", + "pi_name": "N. Gholiza", + "project_access": "open" } ] } diff --git a/geddes/k8s/04-ingress.yaml b/geddes/k8s/04-ingress.yaml index 3af783e..7b4e8f9 100644 --- a/geddes/k8s/04-ingress.yaml +++ b/geddes/k8s/04-ingress.yaml @@ -20,7 +20,16 @@ spec: port: number: 3000 - # 2. Next longest match: /api/dt goes to Next.js server-side proxy + # 2. Next longest match: /api/mcp goes to Next.js MCP endpoint + - path: /api/mcp + pathType: Prefix + backend: + service: + name: dt-web + port: + number: 3000 + + # 3. Next longest match: /api/dt goes to Next.js server-side proxy - path: /api/dt pathType: Prefix backend: @@ -29,7 +38,7 @@ spec: port: number: 3000 - # 3. Next longest match: /api goes to FastAPI + # 4. Next longest match: /api goes to FastAPI # FastAPI is natively configured to handle /api routes so no rewrite is needed! - path: /api pathType: Prefix @@ -39,7 +48,7 @@ spec: port: number: 8000 - # 4. Default match: / goes to Next.js Web App + # 5. Default match: / goes to Next.js Web App - path: / pathType: Prefix backend: diff --git a/web/app/api/assistant/route.ts b/web/app/api/assistant/route.ts new file mode 100644 index 0000000..6e85900 --- /dev/null +++ b/web/app/api/assistant/route.ts @@ -0,0 +1,202 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { resolveMcpSession, type PlatformSession } from "@/lib/mcp/dt-call"; +import { runTool, type ToolResult } from "@/lib/mcp/tools"; + +/** + * In-app AI assistant (Option A). + * + * The user opens the Assistant panel in the web app and asks a question; the + * app talks to the MCP tool layer internally (no manual MCP client setup). This + * route runs in-process against the same tool registry the MCP server exposes, + * so every answer is permission-aware and audited. + * + * Reasoning backend: + * - A local deterministic planner picks the right read-only tool(s) from the + * user's message. No external LLM/API call is made in this phase. + */ + +export const runtime = "nodejs"; + +interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +interface ToolCallTrace { + name: string; + args: Record; + ok: boolean; + count: number | null; +} + +function countOf(data: unknown): number | null { + if (Array.isArray(data)) return data.length; + if (data && typeof data === "object") return 1; + return null; +} + +// ── Local planner ──────────────────────────────────────────────────────────── + +interface NamedProject { + id: string; + name: string; +} + +async function resolveProject( + session: PlatformSession, + query: string, +): Promise { + const result = await runTool(session, "list_projects", {}, "assistant"); + if (!result.ok || !Array.isArray(result.data)) return null; + const projects = result.data as NamedProject[]; + const lower = query.toLowerCase(); + return ( + projects.find((p) => p.id && lower.includes(p.id.toLowerCase())) ?? + projects.find((p) => p.name && lower.includes(p.name.toLowerCase())) ?? + null + ); +} + +function summarize(name: string, result: ToolResult): string { + if (!result.ok) return `\u2022 ${name} failed: ${result.error}`; + const data = result.data; + if (Array.isArray(data)) { + if (data.length === 0) return `\u2022 ${name}: no matching records.`; + const preview = data + .slice(0, 8) + .map((item) => { + if (item && typeof item === "object") { + const o = item as Record; + const label = o.name ?? o.lot_name ?? o.run_id ?? o.id ?? "record"; + const id = o.id ?? o.run_id ?? ""; + return id && id !== label ? `${label} (${id})` : String(label); + } + return String(item); + }) + .join(", "); + const more = data.length > 8 ? ` … and ${data.length - 8} more` : ""; + return `\u2022 ${name}: ${data.length} result(s): ${preview}${more}.`; + } + if (data && typeof data === "object") { + const o = data as Record; + // Composite payload from summarize_project. + if (o.project && typeof o.project === "object") { + const project = o.project as Record; + const members = Array.isArray(o.members) ? o.members.length : 0; + const runs = Array.isArray(o.runs) ? o.runs.length : 0; + const experiments = Array.isArray(o.experiments) ? o.experiments.length : 0; + return `\u2022 ${name}: ${project.name} (${project.id}) — PI ${project.pi ?? "?"}, access ${project.access ?? "?"}, status ${project.status ?? "?"}; ${members} member(s), ${experiments} unit experiment(s), ${runs} run(s).`; + } + const label = o.name ?? o.id ?? "object"; + return `\u2022 ${name}: ${label}${o.id && o.id !== label ? ` (${o.id})` : ""}.`; + } + return `\u2022 ${name}: ${String(data)}`; +} + +async function localPlan( + session: PlatformSession, + query: string, +): Promise<{ reply: string; toolCalls: ToolCallTrace[] }> { + const lower = query.toLowerCase(); + const traces: ToolCallTrace[] = []; + const lines: string[] = []; + + const record = (name: string, args: Record, r: ToolResult) => { + traces.push({ name, args, ok: r.ok, count: r.ok ? countOf(r.data) : null }); + lines.push(summarize(name, r)); + }; + + const has = (...words: string[]) => words.some((w) => lower.includes(w)); + const runIds = (query.match(/\b\d{1,9}\b/g) ?? []).map(Number); + + if (has("summarize", "summary", "overview") && has("project")) { + const project = await resolveProject(session, query); + if (project) { + const r = await runTool(session, "summarize_project", { project_id: project.id }, "assistant"); + record("summarize_project", { project_id: project.id }, r); + } else { + const r = await runTool(session, "list_projects", {}, "assistant"); + record("list_projects", {}, r); + lines.push("(Could not match a specific project name; listing all visible projects.)"); + } + } else if (has("compare") && has("run") && runIds.length >= 2) { + const r = await runTool(session, "compare_runs", { run_ids: runIds }, "assistant"); + record("compare_runs", { run_ids: runIds }, r); + } else if (has("drift", "out of bound", "outlier", "anomaly")) { + const project = await resolveProject(session, query); + const args = project ? { project_id: project.id } : {}; + const r = await runTool(session, "find_out_of_bounds_runs", args, "assistant"); + record("find_out_of_bounds_runs", args, r); + } else if (has("unit experiment", "experiment type", "experiment-type")) { + const r = await runTool(session, "list_unit_experiments", {}, "assistant"); + record("list_unit_experiments", {}, r); + } else if (has("process")) { + const r = await runTool(session, "list_processes", {}, "assistant"); + record("list_processes", {}, r); + } else if (has("equipment", "etch", "etcher", "tool ", "instrument")) { + const r = await runTool(session, "list_equipment", {}, "assistant"); + record("list_equipment", {}, r); + } else if (has("run")) { + const project = await resolveProject(session, query); + const args = project ? { project_id: project.id } : {}; + const r = await runTool(session, "list_project_runs", args, "assistant"); + record("list_project_runs", args, r); + } else if (has("project")) { + const r = await runTool(session, "list_projects", {}, "assistant"); + record("list_projects", {}, r); + } else { + const [projects, equipment] = await Promise.all([ + runTool(session, "list_projects", {}, "assistant"), + runTool(session, "list_equipment", {}, "assistant"), + ]); + record("list_projects", {}, projects); + record("list_equipment", {}, equipment); + lines.unshift( + "I can query equipment, unit experiments, processes, projects and runs. Here is a quick overview:", + ); + } + + return { reply: lines.join("\n"), toolCalls: traces }; +} + +export async function POST(request: NextRequest) { + const session = await resolveMcpSession(); + if (!session) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + + let body: { messages?: ChatMessage[]; query?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const messages: ChatMessage[] = + body.messages && body.messages.length > 0 + ? body.messages + : body.query + ? [{ role: "user", content: body.query }] + : []; + + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + if (!lastUser?.content?.trim()) { + return NextResponse.json({ error: "No user message provided" }, { status: 400 }); + } + + try { + const result = await localPlan(session, lastUser.content); + + return NextResponse.json({ + reply: result.reply, + toolCalls: result.toolCalls, + mode: "local", + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Assistant failed" }, + { status: 500 }, + ); + } +} diff --git a/web/app/api/mcp/route.ts b/web/app/api/mcp/route.ts new file mode 100644 index 0000000..69226db --- /dev/null +++ b/web/app/api/mcp/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { resolveMcpBearerSession, resolveMcpSession } from "@/lib/mcp/dt-call"; +import { listTools, runTool } from "@/lib/mcp/tools"; + +/** + * Model Context Protocol server (Streamable HTTP transport, stateless). + * + * Speaks JSON-RPC 2.0 over POST, exactly like any MCP server, but reuses the + * platform's existing trust boundary: the caller must already hold a valid + * NextAuth session (resolveMcpSession), and every tool runs through runTool -> + * dtCall, so the FastAPI backend + Postgres RLS enforce per-user permissions. + * + * Implemented methods: initialize, tools/list, tools/call, ping. + * This is intentionally dependency-free so it builds cleanly on Next 16; it can + * be swapped for mcp-handler / the official SDK later without touching tools.ts. + * + * Current rollout: disabled by default. The platform is using only the in-app + * assistant surface, so external MCP HTTP access must be explicitly enabled. + */ + +export const runtime = "nodejs"; + +const MCP_HTTP_ENDPOINT_ENABLED = process.env.ENABLE_MCP_HTTP_ENDPOINT === "true"; +const PROTOCOL_VERSION = "2025-06-18"; +const SERVER_INFO = { name: "birck-digital-twin", version: "1.0.0" }; + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: string | number | null; + method: string; + params?: Record; +} + +function rpcResult(id: JsonRpcRequest["id"], result: unknown) { + return { jsonrpc: "2.0" as const, id: id ?? null, result }; +} + +function rpcError( + id: JsonRpcRequest["id"], + code: number, + message: string, + data?: unknown, +) { + return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message, data } }; +} + +async function dispatch( + rpc: JsonRpcRequest, + session: Awaited>, +): Promise { + if (!session) { + return rpcError(rpc.id, -32001, "Authentication required"); + } + + switch (rpc.method) { + case "initialize": + return rpcResult(rpc.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: + "Read-only tools for querying Birck Digital Twin equipment, unit experiments, processes, projects, and runs. All results are scoped to the authenticated user's permissions.", + }); + + case "notifications/initialized": + // Notification (no id): no response body. + return null; + + case "ping": + return rpcResult(rpc.id, {}); + + case "tools/list": + return rpcResult(rpc.id, { + tools: listTools().map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }); + + case "tools/call": { + const params = rpc.params ?? {}; + const name = typeof params.name === "string" ? params.name : ""; + const args = + params.arguments && typeof params.arguments === "object" + ? (params.arguments as Record) + : {}; + if (!name) { + return rpcError(rpc.id, -32602, "Missing tool name"); + } + + const result = await runTool(session, name, args, "mcp"); + const payload = result.ok ? result.data : { error: result.error }; + return rpcResult(rpc.id, { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: result.ok ? { data: result.data } : undefined, + isError: !result.ok, + }); + } + + default: + return rpcError(rpc.id, -32601, `Method not found: ${rpc.method}`); + } +} + +async function resolveMcpHttpSession(request: NextRequest) { + if (process.env.MCP_BEARER_TOKEN?.trim()) { + return resolveMcpBearerSession(request.headers.get("authorization")); + } + + return ( + resolveMcpBearerSession(request.headers.get("authorization")) ?? + (await resolveMcpSession()) + ); +} + +export async function POST(request: NextRequest) { + if (!MCP_HTTP_ENDPOINT_ENABLED) { + return NextResponse.json({ detail: "MCP HTTP endpoint is disabled" }, { status: 404 }); + } + + const session = await resolveMcpHttpSession(request); + if (!session) { + return NextResponse.json( + rpcError(null, -32001, "Authentication required"), + { status: 401 }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json(rpcError(null, -32700, "Parse error"), { status: 400 }); + } + + // Support JSON-RPC batches as well as single calls. + if (Array.isArray(body)) { + const responses = ( + await Promise.all( + body.map((rpc) => dispatch(rpc as JsonRpcRequest, session)), + ) + ).filter((r): r is object => r !== null); + return NextResponse.json(responses); + } + + const response = await dispatch(body as JsonRpcRequest, session); + if (response === null) { + // Notification only — nothing to return. + return new NextResponse(null, { status: 202 }); + } + return NextResponse.json(response); +} + +export async function GET() { + if (!MCP_HTTP_ENDPOINT_ENABLED) { + return NextResponse.json({ detail: "MCP HTTP endpoint is disabled" }, { status: 404 }); + } + + // Stateless transport: no server-initiated SSE stream. + return NextResponse.json( + rpcError(null, -32000, "Method Not Allowed: use POST for JSON-RPC"), + { status: 405 }, + ); +} diff --git a/web/app/assistant/page.tsx b/web/app/assistant/page.tsx new file mode 100644 index 0000000..3a65ef6 --- /dev/null +++ b/web/app/assistant/page.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useRef, useState } from "react"; +import { + Activity, + Bot, + ClipboardCheck, + Cpu, + FolderOpen, + GitBranch, + Send, + Sparkles, + User, + Wrench, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ToolCallTrace { + name: string; + args: Record; + ok: boolean; + count: number | null; +} + +interface ChatTurn { + role: "user" | "assistant"; + content: string; + toolCalls?: ToolCallTrace[]; + mode?: string; + error?: boolean; +} + +interface AssistantCapability { + id: string; + label: string; + icon: LucideIcon; + prompts: string[]; +} + +const CAPABILITIES: AssistantCapability[] = [ + { + id: "equipment", + label: "Find equipment", + icon: Cpu, + prompts: [ + "What equipment is available for etching?", + "List equipment I can access", + "Show details for equipment etcher", + ], + }, + { + id: "experiments", + label: "Browse unit experiments", + icon: ClipboardCheck, + prompts: [ + "List unit experiments I can use", + "What unit experiments exist for etching?", + "Show unit experiments for equipment etcher", + ], + }, + { + id: "processes", + label: "Explore processes", + icon: GitBranch, + prompts: [ + "List processes I can see", + "What processes are available for etching?", + "Show reusable process templates", + ], + }, + { + id: "projects", + label: "Review projects", + icon: FolderOpen, + prompts: [ + "List my projects", + "Summarize my project for a PI", + "Show runs for my project", + ], + }, + { + id: "runs", + label: "Analyze runs", + icon: Activity, + prompts: [ + "Find runs that drifted out of bounds", + "Compare runs 1 and 2", + "Show the latest runs I can access", + ], + }, +]; + +function nextPromptsFor(toolCalls: ToolCallTrace[] | undefined): string[] { + const lastTool = toolCalls?.at(-1)?.name; + switch (lastTool) { + case "list_equipment": + case "get_equipment": + return [ + "List unit experiments I can use", + "What processes are available for etching?", + "List my projects", + ]; + case "list_unit_experiments": + return [ + "List processes I can see", + "Show runs for my project", + "Summarize my project for a PI", + ]; + case "list_processes": + case "get_process": + return [ + "List my projects", + "Show runs for my project", + "Find runs that drifted out of bounds", + ]; + case "list_projects": + case "get_project": + return [ + "Summarize my project for a PI", + "Show runs for my project", + "Find runs that drifted out of bounds", + ]; + case "list_project_runs": + case "get_run": + return [ + "Find runs that drifted out of bounds", + "Compare runs 1 and 2", + "Summarize my project for a PI", + ]; + case "find_out_of_bounds_runs": + return [ + "Show runs for my project", + "Compare runs 1 and 2", + "Summarize my project for a PI", + ]; + case "compare_runs": + case "summarize_project": + return [ + "Find runs that drifted out of bounds", + "List unit experiments I can use", + "List processes I can see", + ]; + default: + return []; + } +} + +export default function AssistantPage() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [activeCapability, setActiveCapability] = useState(CAPABILITIES[0]); + const listRef = useRef(null); + + async function send(text: string) { + const question = text.trim(); + if (!question || loading) return; + + const history: ChatTurn[] = [...messages, { role: "user", content: question }]; + setMessages(history); + setInput(""); + setLoading(true); + + try { + const response = await fetch("/api/assistant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ + messages: history.map((m) => ({ role: m.role, content: m.content })), + }), + }); + + const data = await response.json(); + if (!response.ok) { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: data.error ?? "Request failed.", error: true }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: data.reply || "(no answer)", + toolCalls: data.toolCalls, + mode: data.mode, + }, + ]); + } + } catch { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: "Network error contacting the assistant.", error: true }, + ]); + } finally { + setLoading(false); + requestAnimationFrame(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" }); + }); + } + } + + return ( +
+
+
+ +
+
+

AI Assistant

+

+ Permission-aware querying of equipment, experiments, processes, projects and runs. +

+
+
+ +
+ {messages.length === 0 && ( +
+
+

What do you want to do?

+

+ Choose a read-only capability, then pick a focused question. +

+
+ +
+ {CAPABILITIES.map((capability) => { + const Icon = capability.icon; + const selected = activeCapability.id === capability.id; + return ( + + ); + })} +
+ +
+

+ Suggested questions +

+
+ {activeCapability.prompts.map((prompt) => ( + + ))} +
+
+
+ )} + + {messages.map((m, i) => ( +
+
+ {m.role === "user" ? : } +
+
+
+ {m.content} +
+ {m.toolCalls && m.toolCalls.length > 0 && ( + <> +
+ {m.toolCalls.map((tc, j) => ( + + + {tc.name} + {tc.count !== null && ` · ${tc.count}`} + + ))} +
+ {m.role === "assistant" && nextPromptsFor(m.toolCalls).length > 0 && ( +
+ {nextPromptsFor(m.toolCalls).map((prompt) => ( + + ))} +
+ )} + + )} +
+
+ ))} + + {loading && ( +
+
+ +
+
+ Querying platform data… +
+
+ )} +
+ +
{ + e.preventDefault(); + void send(input); + }} + className="flex items-center gap-2 pt-4 border-t border-[hsl(var(--border))]" + > + setInput(e.target.value)} + placeholder="Ask about equipment, projects, runs…" + className="flex-1 px-4 py-2.5 rounded-lg bg-[hsl(var(--card))] border border-[hsl(var(--border))] text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]" + /> + +
+
+ ); +} diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx index 3bd40a3..f782695 100644 --- a/web/components/sidebar.tsx +++ b/web/components/sidebar.tsx @@ -29,6 +29,7 @@ import { LogOut, Globe, GitBranch, + Sparkles, } from "lucide-react"; import { useTheme } from "next-themes"; import { useState } from "react"; @@ -39,6 +40,7 @@ const NAV_SECTIONS = [ label: null, items: [ { id: "dashboard", label: "Dashboard", href: "/", icon: LayoutDashboard }, + { id: "assistant", label: "AI Assistant", href: "/assistant", icon: Sparkles }, { id: "equipment", label: "Equipment", href: "/equipment/list", icon: Cpu }, { id: "projects", label: "Projects", href: "/projects", icon: FolderOpen }, { id: "experiments", label: "Unit Experiments", href: "/experiment", icon: ClipboardCheck }, diff --git a/web/lib/auth-context.tsx b/web/lib/auth-context.tsx index a460e7c..9774e36 100644 --- a/web/lib/auth-context.tsx +++ b/web/lib/auth-context.tsx @@ -81,7 +81,7 @@ export const ROLE_PERMISSIONS: Record< canInviteToOwnProjects: true, visibleNavSections: ["main", "data", "ml", "admin"], visibleNavItems: [ - "dashboard", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", + "dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", "samples", "upload", "ingestion", "catalog", "parity", "importance", "convergence", "proposals", "users", "reviews", "execution_queue", "settings", ], @@ -97,7 +97,7 @@ export const ROLE_PERMISSIONS: Record< canInviteToOwnProjects: true, visibleNavSections: ["main", "data", "ml", "admin"], visibleNavItems: [ - "dashboard", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", + "dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", "samples", "upload", "ingestion", "catalog", "parity", "importance", "convergence", "proposals", "settings", ], @@ -113,7 +113,7 @@ export const ROLE_PERMISSIONS: Record< canInviteToOwnProjects: false, visibleNavSections: ["main", "data", "ml", "admin"], visibleNavItems: [ - "dashboard", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "analytics", + "dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "analytics", "samples", "upload", "ingestion", "catalog", "execution_queue", "settings", ], }, @@ -128,7 +128,7 @@ export const ROLE_PERMISSIONS: Record< canInviteToOwnProjects: false, visibleNavSections: ["main", "data", "ml", "admin"], visibleNavItems: [ - "dashboard", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", + "dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics", "samples", "upload", "catalog", "parity", "importance", "convergence", "proposals", "settings", ], diff --git a/web/lib/mcp/dt-call.ts b/web/lib/mcp/dt-call.ts new file mode 100644 index 0000000..1eb83a3 --- /dev/null +++ b/web/lib/mcp/dt-call.ts @@ -0,0 +1,188 @@ +import "server-only"; + +import { auth } from "@/auth"; +import { requireServerEnv } from "@/lib/server-env"; +import { timingSafeEqual } from "crypto"; + +/** + * dt-call.ts — the trust boundary for the MCP / AI layer. + * + * The MCP server and the in-app AI assistant are just additional consumers of + * the same boundary the browser proxy (app/api/dt/[...path]/route.ts) already + * uses: resolve the signed-in user, then call FastAPI with the DT system token + * plus X-User-* identity headers. FastAPI (get_platform_user) only trusts the + * identity headers when the system token matches, and Postgres RLS enforces + * per-user visibility. Nothing here re-implements permissions — every tool + * inherits them by routing through this helper. + */ + +const INTERNAL_API_BASE_URL = ( + process.env.DT_API_URL_INTERNAL || + process.env.NEXT_PUBLIC_API_URL || + "http://dt-api:8000/api" +).replace(/\/$/, ""); + +// Mirrors the proxy's DEMO_MODE escape hatch: in non-production dev, an +// unauthenticated caller is treated as ngholiza so the assistant can be +// exercised locally without a real NanoHUB sign-in. Production ignores it. +const DEMO_MODE_ALLOWED = + process.env.NODE_ENV !== "production" && process.env.DEMO_MODE === "true"; + +export interface PlatformSession { + id: string; + email: string; + name: string; + role: string; + organization: string; +} + +export async function resolveMcpSession(): Promise { + const session = await auth(); + if (session?.user?.id || session?.user?.email) { + return { + id: session.user.id || session.user.email!, + email: session.user.email || session.user.id, + name: session.user.name || session.user.email || session.user.id, + role: session.user.role || "researcher", + organization: session.user.organization || "Purdue University", + }; + } + + if (DEMO_MODE_ALLOWED) { + return { + id: "ngholiza", + email: "ngholiza@purdue.edu", + name: "N. Gholiza", + role: "pi", + organization: "Birck Nanotechnology Center", + }; + } + + return null; +} + +function bearerTokenFromHeader(authorization: string | null): string { + if (!authorization) return ""; + const [scheme, ...rest] = authorization.trim().split(/\s+/); + if (scheme?.toLowerCase() !== "bearer") return ""; + return rest.join(" ").trim(); +} + +function constantTimeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function normalizePlatformRole(role: string): string { + const normalized = role.trim().toLowerCase(); + if (normalized === "administrator") return "admin"; + return normalized || "researcher"; +} + +export function resolveMcpBearerSession(authorization: string | null): PlatformSession | null { + const expectedToken = process.env.MCP_BEARER_TOKEN?.trim() ?? ""; + const receivedToken = bearerTokenFromHeader(authorization); + if (!expectedToken || !receivedToken) return null; + if (!constantTimeEquals(receivedToken, expectedToken)) return null; + + return { + id: process.env.MCP_TEST_USER_ID?.trim() || "ngholiza", + email: process.env.MCP_TEST_USER_EMAIL?.trim() || "ngholiza@purdue.edu", + name: process.env.MCP_TEST_USER_NAME?.trim() || "N. Gholiza", + role: normalizePlatformRole(process.env.MCP_TEST_USER_ROLE ?? "pi"), + organization: + process.env.MCP_TEST_USER_ORG?.trim() || "Birck Nanotechnology Center", + }; +} + +export interface DtCallResult { + ok: boolean; + status: number; + data: T | null; + error: string | null; +} + +export async function dtCall( + session: PlatformSession, + path: string, + init: RequestInit = {}, +): Promise> { + const headers = new Headers(init.headers); + headers.set("X-System-Token", requireServerEnv("DT_SYSTEM_TOKEN")); + headers.set("X-User-Id", session.id); + headers.set("X-User-Email", session.email); + headers.set("X-User-Name", session.name); + headers.set("X-User-Role", session.role); + headers.set("X-User-Org", session.organization); + headers.set("Accept", "application/json"); + + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = `${INTERNAL_API_BASE_URL}${normalizedPath}`; + + try { + const response = await fetch(url, { + ...init, + headers, + cache: "no-store", + }); + + let parsed: unknown = null; + const text = await response.text(); + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + } + + if (!response.ok) { + const detail = + parsed && typeof parsed === "object" && "detail" in parsed + ? String((parsed as { detail: unknown }).detail) + : `Request failed with status ${response.status}`; + return { ok: false, status: response.status, data: null, error: detail }; + } + + return { ok: true, status: response.status, data: (parsed as T) ?? null, error: null }; + } catch (err) { + return { + ok: false, + status: 0, + data: null, + error: err instanceof Error ? err.message : "Failed to contact DT API backend", + }; + } +} + +/** + * Audit log for every MCP / AI tool invocation (MCP_AI_SUPPORT_PLAN.md §7). + * + * For now this writes a structured line to the server log, which is the same + * sink the FastAPI request-tracing middleware uses. It can later be pointed at + * a dedicated mcp_audit table without changing any call sites. + */ +export interface ToolAuditEntry { + userId: string; + tool: string; + params: Record; + returnedCount: number | null; + success: boolean; + error?: string | null; + source: "mcp" | "assistant"; +} + +export function logToolCall(entry: ToolAuditEntry): void { + console.log( + "[MCP_AUDIT]", + JSON.stringify({ timestamp: new Date().toISOString(), ...entry }), + ); +} + +export function countResult(data: unknown): number | null { + if (Array.isArray(data)) return data.length; + if (data && typeof data === "object") return 1; + return null; +} diff --git a/web/lib/mcp/tools.ts b/web/lib/mcp/tools.ts new file mode 100644 index 0000000..43aab5b --- /dev/null +++ b/web/lib/mcp/tools.ts @@ -0,0 +1,402 @@ +import "server-only"; + +import { + countResult, + dtCall, + logToolCall, + type PlatformSession, +} from "@/lib/mcp/dt-call"; + +/** + * tools.ts — the read-only MCP tool layer (MCP_AI_SUPPORT_PLAN.md §2). + * + * Each tool is a thin wrapper over an existing FastAPI endpoint, routed through + * dtCall so it inherits the user's permissions. This single registry powers + * both surfaces: + * - the MCP server (app/api/mcp/route.ts) — for MCP clients + * - the in-app AI assistant (app/api/assistant/route.ts) — Option A + * + * Only read / summarize / compare tools are exposed. Write tools (create, + * delete, publish) are intentionally omitted until permission checks and audit + * logging are mature (plan §3). + */ + +type JsonSchema = { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +export interface ToolResult { + ok: boolean; + data: unknown; + error: string | null; +} + +export interface ToolDef { + name: string; + title: string; + description: string; + inputSchema: JsonSchema; + execute: ( + session: PlatformSession, + args: Record, + ) => Promise; +} + +function str(args: Record, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function num(args: Record, key: string): number | undefined { + const value = args[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() && !Number.isNaN(Number(value))) { + return Number(value); + } + return undefined; +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + const candidate = value === undefined ? fallback : Math.trunc(value); + return Math.min(Math.max(candidate, min), max); +} + +function ok(data: unknown): ToolResult { + return { ok: true, data, error: null }; +} + +function fail(error: string): ToolResult { + return { ok: false, data: null, error }; +} + +// ── Tool definitions ───────────────────────────────────────────────────────── + +const TOOL_LIST: ToolDef[] = [ + { + name: "list_equipment", + title: "List equipment", + description: + "List all equipment the current user can see, with type and location. Use for capability/equipment discovery (e.g. etchers).", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + async execute(session) { + const r = await dtCall(session, "/equipment/list/simple"); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to list equipment"); + }, + }, + { + name: "get_equipment", + title: "Get equipment detail", + description: + "Get full detail for one piece of equipment by its domain id, including parameters, outputs and operational status.", + inputSchema: { + type: "object", + properties: { + equipment_id: { type: "string", description: "The equipment domain id." }, + }, + required: ["equipment_id"], + additionalProperties: false, + }, + async execute(session, args) { + const id = str(args, "equipment_id"); + if (!id) return fail("equipment_id is required"); + const r = await dtCall(session, `/equipment/detail/${encodeURIComponent(id)}`); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to get equipment"); + }, + }, + { + name: "list_unit_experiments", + title: "List unit experiments", + description: + "List unit experiment types the user can see, optionally filtered to a single equipment id.", + inputSchema: { + type: "object", + properties: { + equipment_id: { + type: "string", + description: "Optional equipment id to filter unit experiments.", + }, + }, + additionalProperties: false, + }, + async execute(session, args) { + const id = str(args, "equipment_id"); + const query = id ? `?equipment_id=${encodeURIComponent(id)}` : ""; + const r = await dtCall(session, `/equipment/experiment-types${query}`); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to list unit experiments"); + }, + }, + { + name: "list_processes", + title: "List processes", + description: "List process definitions (multi-step workflows) the user can see.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + async execute(session) { + const r = await dtCall(session, "/dataset/v2/processes"); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to list processes"); + }, + }, + { + name: "get_process", + title: "Get process detail", + description: "Get a single process definition and its ordered steps by process id.", + inputSchema: { + type: "object", + properties: { process_id: { type: "string", description: "The process id." } }, + required: ["process_id"], + additionalProperties: false, + }, + async execute(session, args) { + const id = str(args, "process_id"); + if (!id) return fail("process_id is required"); + const r = await dtCall(session, `/dataset/v2/processes/${encodeURIComponent(id)}`); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to get process"); + }, + }, + { + name: "list_projects", + title: "List projects", + description: "List all projects visible to the current user, with PI, access level and status.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + async execute(session) { + const r = await dtCall(session, "/dataset/v2/projects"); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to list projects"); + }, + }, + { + name: "get_project", + title: "Get project", + description: + "Get a single project by id (resolved from the user's visible projects), including name, PI, access and status.", + inputSchema: { + type: "object", + properties: { project_id: { type: "string", description: "The project id." } }, + required: ["project_id"], + additionalProperties: false, + }, + async execute(session, args) { + const id = str(args, "project_id"); + if (!id) return fail("project_id is required"); + const r = await dtCall>(session, "/dataset/v2/projects"); + if (!r.ok) return fail(r.error ?? "Failed to load projects"); + const match = (r.data ?? []).find((p) => p.id === id); + return match ? ok(match) : fail(`Project ${id} not found or not visible`); + }, + }, + { + name: "list_project_runs", + title: "List project runs", + description: + "List runs for a project (or all visible runs if no project given). Returns run id, lot, date, etch rate and outlier flags.", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "Optional project id to scope runs." }, + include_outliers: { + type: "boolean", + description: "Include outlier/calibration runs (default false).", + }, + limit: { + type: "number", + description: "Max runs to return. Clamped to 1-200; default 100.", + minimum: 1, + maximum: 200, + }, + }, + additionalProperties: false, + }, + async execute(session, args) { + const params = new URLSearchParams(); + const projectId = str(args, "project_id"); + if (projectId) params.set("project_id", projectId); + if (args.include_outliers === true) params.set("include_outliers", "true"); + const limit = clampInt(num(args, "limit"), 100, 1, 200); + params.set("limit", String(limit)); + const r = await dtCall(session, `/dataset/v2/runs?${params.toString()}`); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to list runs"); + }, + }, + { + name: "get_run", + title: "Get run detail", + description: "Get full detail for a single run by run id, including features and file references.", + inputSchema: { + type: "object", + properties: { run_id: { type: "number", description: "The numeric run id." } }, + required: ["run_id"], + additionalProperties: false, + }, + async execute(session, args) { + const runId = num(args, "run_id"); + if (runId === undefined) return fail("run_id is required"); + const r = await dtCall(session, `/dataset/v2/runs/${encodeURIComponent(String(runId))}`); + return r.ok ? ok(r.data) : fail(r.error ?? "Failed to get run"); + }, + }, + { + name: "find_out_of_bounds_runs", + title: "Find out-of-bounds runs", + description: + "Find runs flagged as outliers (out of expected bounds / drifted) for a project or across all visible runs.", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "Optional project id to scope the search." }, + }, + additionalProperties: false, + }, + async execute(session, args) { + const params = new URLSearchParams(); + const projectId = str(args, "project_id"); + if (projectId) params.set("project_id", projectId); + params.set("include_outliers", "true"); + params.set("limit", "200"); + const r = await dtCall>( + session, + `/dataset/v2/runs?${params.toString()}`, + ); + if (!r.ok) return fail(r.error ?? "Failed to load runs"); + const outliers = (r.data ?? []).filter((run) => run.is_outlier === true); + return ok(outliers); + }, + }, + { + name: "compare_runs", + title: "Compare runs", + description: + "Fetch several runs by id so their recipes and outputs can be compared side by side.", + inputSchema: { + type: "object", + properties: { + run_ids: { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 10, + description: "List of numeric run ids to compare. Requires 2-10 ids.", + }, + }, + required: ["run_ids"], + additionalProperties: false, + }, + async execute(session, args) { + const raw = args.run_ids; + if (!Array.isArray(raw) || raw.length < 2) { + return fail("run_ids must be an array of at least 2 run ids"); + } + if (raw.length > 10) { + return fail("run_ids can include at most 10 run ids"); + } + const parsedIds = raw + .map((v) => (typeof v === "number" ? v : Number(v))) + .filter((v): v is number => Number.isFinite(v)); + const ids = [...new Set(parsedIds)]; + if (ids.length < 2) { + return fail("run_ids must include at least 2 valid numeric run ids"); + } + const runs = await Promise.all( + ids.map(async (id) => { + const r = await dtCall(session, `/dataset/v2/runs/${encodeURIComponent(String(id))}`); + return { run_id: id, ok: r.ok, data: r.data, error: r.error }; + }), + ); + return ok(runs); + }, + }, + { + name: "summarize_project", + title: "Summarize project", + description: + "Gather a project's metadata, members, unit experiments and runs in one call so the assistant can summarize it for a PI, researcher or student.", + inputSchema: { + type: "object", + properties: { project_id: { type: "string", description: "The project id to summarize." } }, + required: ["project_id"], + additionalProperties: false, + }, + async execute(session, args) { + const id = str(args, "project_id"); + if (!id) return fail("project_id is required"); + + const [projects, members, runs, experiments] = await Promise.all([ + dtCall>(session, "/dataset/v2/projects"), + dtCall(session, `/dataset/v2/projects/${encodeURIComponent(id)}/members`), + dtCall(session, `/dataset/v2/runs?project_id=${encodeURIComponent(id)}&limit=200`), + dtCall(session, `/equipment/experiments?project_id=${encodeURIComponent(id)}`), + ]); + + const project = projects.ok + ? (projects.data ?? []).find((p) => p.id === id) ?? null + : null; + if (!project) return fail(`Project ${id} not found or not visible`); + + return ok({ + project, + members: members.ok ? members.data : [], + runs: runs.ok ? runs.data : [], + experiments: experiments.ok ? experiments.data : [], + }); + }, + }, +]; + +export const TOOLS: ReadonlyMap = new Map( + TOOL_LIST.map((tool) => [tool.name, tool]), +); + +export function listTools(): ToolDef[] { + return [...TOOL_LIST]; +} + +/** + * Run a tool by name with audit logging. Used by both the MCP endpoint and the + * in-app assistant so every invocation is logged identically (plan §7). + */ +export async function runTool( + session: PlatformSession, + name: string, + args: Record, + source: "mcp" | "assistant", +): Promise { + const tool = TOOLS.get(name); + if (!tool) { + logToolCall({ + userId: session.id, + tool: name, + params: args, + returnedCount: null, + success: false, + error: "unknown tool", + source, + }); + return fail(`Unknown tool: ${name}`); + } + + try { + const result = await tool.execute(session, args); + logToolCall({ + userId: session.id, + tool: name, + params: args, + returnedCount: result.ok ? countResult(result.data) : null, + success: result.ok, + error: result.error, + source, + }); + return result; + } catch (err) { + const error = err instanceof Error ? err.message : "Tool execution failed"; + logToolCall({ + userId: session.id, + tool: name, + params: args, + returnedCount: null, + success: false, + error, + source, + }); + return fail(error); + } +}