507 lines
18 KiB
TypeScript
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) }
|
|
});
|
|
}
|
|
}
|