507 lines
18 KiB
TypeScript

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<string, unknown>
: { 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<typeof actorFromRequest>) {
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) }
});
}
}