import type { Request, Response } from "express"; import { acknowledgeAgentMessage, addStoryEdge, auditStorySources, addStoryNode, completeCounterEvidenceCheck, createStoryThread, enrichEvidenceDocument, getAgentMessage, getEvidence, getOpportunity, getStoryThread, listAgentMessages, listOpportunities, listSources, listStoryThreads, markOpportunity, postAgentMessage, searchEvidence, setStoryNodeReviewState, sourceStatus, evaluateSourceTrust, listSourceTrustPolicy } from "./db.js"; import { syncAll } from "./sync.js"; import type { SourceId } from "./types.js"; import { mediaRoleAtLeast, type MediaRole } from "./auth.js"; const tools = [ { name: "sources_list", description: "List configured public, market and first-party corporate information sources.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "sources_status", description: "Show sync health and counts for each source.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "source_trust_policy", description: "Return the current editorial source-trust policy: domains, weights, source classes and independence groups. Weights are configurable priors, not guarantees that a claim is true.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "source_trust_evaluate", description: "Evaluate a set of source URLs/domains/source IDs against the editorial trust policy. Sources in the same independence group are deduplicated; genuinely independent groups add a small corroboration bonus.", inputSchema: { type: "object", properties: { sources: { type: "array", minItems: 1, maxItems: 50, items: { type: "object", properties: { url: { type: "string" }, domain: { type: "string" }, source_id: { type: "string" } }, additionalProperties: false } } }, required: ["sources"], additionalProperties: false } }, { name: "sources_sync", description: "Run ingestion now for one source or all sources.", inputSchema: { type: "object", properties: { source_id: { type: "string", enum: ["geonet", "parliament", "stats-nz", "data-govt-nz", "treasury", "rbnz", "mbie-jobs-online", "companies-office", "nz-gazette", "electricity-authority", "nzx-announcements", "google-news", "meta-news", "anthropic-news", "openai-news"] } }, additionalProperties: false } }, { name: "opportunities_list", description: "List potential media opportunities surfaced from public-source changes.", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["new", "watch", "investigate", "develop", "ignore", "closed"] }, since: { type: "string", description: "ISO-8601 timestamp" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false } }, { name: "opportunity_get", description: "Get one opportunity with its source signal, provenance evidence, and notes.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "opportunity_mark", description: "Human triage action: mark an opportunity as new, watch, investigate, develop, ignore, or closed.", inputSchema: { type: "object", properties: { id: { type: "string" }, status: { type: "string", enum: ["new", "watch", "investigate", "develop", "ignore", "closed"] } }, required: ["id", "status"], additionalProperties: false } }, { name: "evidence_get", description: "Retrieve a stored evidence record and its source provenance.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "evidence_enrich", description: "Fetch and freeze the actual source document behind an evidence record, replacing discovery-only evidence with a source-document snapshot when possible.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "evidence_search", description: "Search stored evidence and source text.", inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, required: ["query"], additionalProperties: false } }, { name: "messages_list", description: "List agent activity messages for the human media team.", inputSchema: { type: "object", properties: { acknowledged: { type: "boolean" }, severity: { type: "string", enum: ["info", "notice", "warning", "error"] }, since: { type: "string", description: "ISO-8601 timestamp" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false } }, { name: "message_get", description: "Get one agent activity message.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "message_post", description: "Post a human-readable activity update from the authenticated MCP agent. The author identity is taken from OAuth and cannot be supplied by the caller.", inputSchema: { type: "object", properties: { title: { type: "string", maxLength: 180 }, body: { type: "string", maxLength: 8000 }, severity: { type: "string", enum: ["info", "notice", "warning", "error"] }, kind: { type: "string", enum: ["update", "finding", "request", "warning", "error"] }, related_type: { type: "string", enum: ["opportunity", "source", "evidence", "story", "general"] }, related_id: { type: "string" } }, required: ["title", "body"], additionalProperties: false } }, { name: "message_acknowledge", description: "Acknowledge an agent activity message.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "story_threads_list", description: "List 360-degree research story threads and their graph/research status.", inputSchema: { type: "object", properties: { status: { type: "string", enum: ["researching", "review", "develop", "closed"] }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false } }, { name: "story_thread_get", description: "Get one story thread with all graph nodes, labelled edges and research checks.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } }, { name: "story_thread_create", description: "Create a candidate 360-degree research thread. This does not approve, draft or publish a story.", inputSchema: { type: "object", properties: { title: { type: "string", maxLength: 220 }, question: { type: "string", maxLength: 1200 } }, required: ["title"], additionalProperties: false } }, { name: "story_node_add", description: "Add a research node to a story graph with optional provenance links to an existing signal, evidence record, opportunity or agent message.", inputSchema: { type: "object", properties: { thread_id: { type: "string" }, node_type: { type: "string", enum: ["signal","evidence","claim","counterclaim","trend","forecast","actual","question","context","organisation","person","place","metric"] }, title: { type: "string", maxLength: 240 }, summary: { type: "string", maxLength: 8000 }, source_id: { type: "string" }, signal_id: { type: "string" }, evidence_id: { type: "string" }, opportunity_id: { type: "string" }, message_id: { type: "string" }, confidence: { type: "number", minimum: 0, maximum: 1 } }, required: ["thread_id","node_type","title"], additionalProperties: false } }, { name: "story_node_review", description: "Human/editorial review action: mark a Story Thread node active, resolved, or irrelevant. Irrelevant nodes stop contributing to the sourcing audit.", inputSchema: { type: "object", properties: { thread_id: { type: "string" }, node_id: { type: "string" }, review_state: { type: "string", enum: ["active","resolved","irrelevant"] } }, required: ["thread_id","node_id","review_state"], additionalProperties: false } }, { name: "story_edge_add", description: "Connect two nodes in a story graph with an explicit evidentiary relationship.", inputSchema: { type: "object", properties: { thread_id: { type: "string" }, from_node_id: { type: "string" }, to_node_id: { type: "string" }, relationship: { type: "string", enum: ["supports","contradicts","qualifies","explains","correlates_with","precedes","updates","supersedes","same_subject","regional_breakdown_of","forecast_for","actual_for","investigates"] }, reason: { type: "string", maxLength: 2000 } }, required: ["thread_id","from_node_id","to_node_id","relationship"], additionalProperties: false } }, { name: "story_source_audit", description: "Audit a story thread for robust sourcing: immutable evidence snapshots, independent source coverage, unsupported claims, source-tag-only nodes and stale evidence.", inputSchema: { type: "object", properties: { thread_id: { type: "string" } }, required: ["thread_id"], additionalProperties: false } }, { name: "story_counter_check_complete", description: "Record the mandatory counter-evidence pass for a story thread, including sources checked and whether material counter-evidence was found.", inputSchema: { type: "object", properties: { thread_id: { type: "string" }, result: { type: "string", enum: ["found","none_found"] }, sources_checked: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 50 }, summary: { type: "string", maxLength: 8000 } }, required: ["thread_id","result","sources_checked","summary"], additionalProperties: false } } ]; function result(data: unknown) { const structuredContent = data !== null && typeof data === "object" && !Array.isArray(data) ? data as Record : { items: data }; return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], structuredContent }; } function actorFromRequest(req: Request) { const auth = (req as any).auth ?? {}; return { sub: String(auth.sub ?? "unknown"), name: String(auth.preferred_username ?? auth.email ?? auth.name ?? auth.sub ?? "OAuth agent"), clientId: String(auth.azp ?? auth.client_id ?? process.env.KEYCLOAK_CLIENT_ID ?? "unknown"), role: String(auth.media_role ?? "viewer") as MediaRole }; } const EDITOR_TOOLS = new Set([ "sources_sync", "opportunity_mark", "evidence_enrich", "message_post", "message_acknowledge", "story_thread_create", "story_node_add", "story_node_review", "story_edge_add", "story_counter_check_complete" ]); function requireToolRole(name: string, role: MediaRole) { if (EDITOR_TOOLS.has(name) && !mediaRoleAtLeast(role, "editor")) { throw new Error(`The ${name} action requires the Media Intelligence editor role. Current role: ${role}.`); } } async function callTool(name: string, args: any, actor: ReturnType) { requireToolRole(name, actor.role); if (name === "sources_list") return result(await listSources()); if (name === "sources_status") return result(await sourceStatus()); if (name === "source_trust_policy") return result(await listSourceTrustPolicy({ enabledOnly: true })); if (name === "source_trust_evaluate") return result(await evaluateSourceTrust( (args.sources ?? []).map((item: any) => ({ url: item.url, domain: item.domain, sourceId: item.source_id })) )); if (name === "sources_sync") return result(await syncAll(args?.source_id as SourceId | undefined)); if (name === "opportunities_list") return result(await listOpportunities(args ?? {})); if (name === "opportunity_get") return result(await getOpportunity(args.id)); if (name === "opportunity_mark") return result(await markOpportunity(args.id, args.status)); if (name === "evidence_get") return result(await getEvidence(args.id)); if (name === "evidence_enrich") return result(await enrichEvidenceDocument(args.id)); if (name === "evidence_search") return result(await searchEvidence(args.query, args.limit)); if (name === "messages_list") return result(await listAgentMessages({ acknowledged: args?.acknowledged, severity: args?.severity, since: args?.since, limit: args?.limit })); if (name === "message_get") return result(await getAgentMessage(args.id)); if (name === "message_post") return result(await postAgentMessage({ title: args.title, body: args.body, severity: args.severity, kind: args.kind, relatedType: args.related_type, relatedId: args.related_id }, actor)); if (name === "message_acknowledge") return result(await acknowledgeAgentMessage( args.id, `${actor.clientId}:${actor.name}` )); if (name === "story_threads_list") return result(await listStoryThreads(args ?? {})); if (name === "story_thread_get") return result(await getStoryThread(args.id)); if (name === "story_thread_create") return result(await createStoryThread({ title: args.title, question: args.question }, actor)); if (name === "story_node_add") return result(await addStoryNode(args.thread_id, { nodeType: args.node_type, title: args.title, summary: args.summary, sourceId: args.source_id, signalId: args.signal_id, evidenceId: args.evidence_id, opportunityId: args.opportunity_id, messageId: args.message_id, confidence: args.confidence }, actor)); if (name === "story_node_review") return result(await setStoryNodeReviewState( args.thread_id, args.node_id, args.review_state, actor )); if (name === "story_edge_add") return result(await addStoryEdge(args.thread_id, { fromNodeId: args.from_node_id, toNodeId: args.to_node_id, relationship: args.relationship, reason: args.reason }, actor)); if (name === "story_source_audit") return result(await auditStorySources(args.thread_id)); if (name === "story_counter_check_complete") return result(await completeCounterEvidenceCheck(args.thread_id, { result: args.result, sourcesChecked: args.sources_checked, summary: args.summary }, actor)); throw new Error(`Unknown tool: ${name}`); } export async function handleMcp(req: Request, res: Response) { const body = req.body ?? {}; const id = body.id; const method = body.method; if (!id && method?.startsWith("notifications/")) { return res.status(202).end(); } try { let response: any; if (method === "initialize") { response = { protocolVersion: "2025-06-18", capabilities: { tools: {}, resources: {} }, serverInfo: { name: "media-intelligence-mcp", version: "0.1.0" } }; } else if (method === "ping") { response = {}; } else if (method === "tools/list") { response = { tools }; } else if (method === "tools/call") { response = await callTool( body.params?.name, body.params?.arguments ?? {}, actorFromRequest(req) ); } else if (method === "resources/list") { response = { resources: [ { uri: "media://sources", name: "Configured media intelligence sources", mimeType: "application/json" }, { uri: "media://source-trust-policy", name: "Editorial source trust policy", mimeType: "application/json" } ] }; } else if (method === "resources/templates/list") { response = { resourceTemplates: [ { uriTemplate: "media://opportunities/{id}", name: "Media opportunity", mimeType: "application/json" }, { uriTemplate: "media://evidence/{id}", name: "Source evidence", mimeType: "application/json" }, { uriTemplate: "media://messages/{id}", name: "Agent activity message", mimeType: "application/json" }, { uriTemplate: "media://stories/{id}", name: "360-degree story research graph", mimeType: "application/json" } ] }; } else if (method === "resources/read") { const uri = String(body.params?.uri ?? ""); if (uri === "media://sources") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSources(), null, 2) }] }; else if (uri === "media://source-trust-policy") response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await listSourceTrustPolicy({ enabledOnly: true }), null, 2) }] }; else if (uri.startsWith("media://opportunities/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getOpportunity(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://evidence/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getEvidence(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://messages/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getAgentMessage(uri.split("/").pop()!), null, 2) }] }; else if (uri.startsWith("media://stories/")) response = { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(await getStoryThread(uri.split("/").pop()!), null, 2) }] }; else throw new Error("Unknown resource URI"); } else { return res.status(404).json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32601, message: `Method not found: ${method}` } }); } return res.json({ jsonrpc: "2.0", id, result: response }); } catch (error) { return res.status(200).json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }); } }