}) {
+ if (!sources || !(sources.length > 1 || sources.some((s) => s.remote))) return null;
+ const day = (t: string | null) => (t ? t.slice(0, 10) : "import date unknown");
+ const studies = (s: AnalysisSource) => s.studies.map((x) => (names.get(x.study) || x.study) + ", imported " + day(x.imported)).join("; ");
+ return (
+
+ Data from:{" "}
+ {sources.map((s, i) => {
+ const peer = s.remote ? [
+ "over MCP at " + (s.url || "an unknown address"),
+ s.reportedName ? "which calls itself “" + s.reportedName + "”" : "which has not described itself",
+ s.build ? "build " + s.build : null,
+ s.answeredAt ? "first answered " + new Date(s.answeredAt).toLocaleString() : null
+ ].filter(Boolean).join(", ") : "this deployment's own database" + (s.build ? ", build " + s.build : "");
+ return (
+ x.study + ": imported " + (x.imported || "unknown")).join("\n")}>
+ {i > 0 && " · "}{s.label}
+ {" "}({s.remote ? "via MCP" + (s.reportedName && s.reportedName !== s.label ? ", calls itself “" + s.reportedName + "”" : "") : "local"}) — {studies(s)}
+
+ );
+ })}
+
+ );
+}
+
/**
* Every view's header, with one anatomy: the title and what it is, then the actions that
* belong to this view, then its status chips, then the controls that reorder and remove it.
diff --git a/lib/analysis.ts b/lib/analysis.ts
index b1b5d8ea..dc84cf83 100644
--- a/lib/analysis.ts
+++ b/lib/analysis.ts
@@ -13,6 +13,7 @@ import type { AnalysisDoc, AnalysisResult, CohortDef, PartitionDef, ViewResult,
import { resolveSplitBy, type AttributeDistribution } from "./cohort-splitby.ts";
import { resolverFor, PatientLevelResolver, AggregateResolver, studyRouting, restrictedStudies, studyAccess, type Resolver } from "./federation.ts";
import { cantDraw } from "./view-coverage.ts";
+import { describeSources, sourceIndex } from "./provenance.ts";
import { resolveParams } from "./params.ts";
import { resolveExtends } from "./cohort-extends.ts";
import { resolvePartitions, comparableChildren, assertPartitionChildrenFit } from "./cohort-partitions.ts";
@@ -322,14 +323,18 @@ export async function runAnalysis(template: AnalysisDoc | TemplateDoc, opts: Run
// ---- views --------------------------------------------------------------
// Every view records which studies answered it and in what mode, so a reader
// never has to infer from prose whether patient-level data was involved.
+ // each names its source in the result's `sources` (lib/provenance.ts)
+ const sourceOf = sourceIndex(involved, resolvers);
const participationFor = (studies: string[], requiresPatientLevel: boolean): ViewParticipation => {
const per = studies.map((s) => {
const inst = resolvers[s] ? resolvers[s].instance : null;
+ const source = sourceOf.get(s);
return {
study: s,
mode: (inst ? inst.mode : "patient-level") as "patient-level" | "aggregate-only",
instance: inst ? inst.label : "unknown",
- ...(inst && inst.id === "peer" ? { remote: true } : {})
+ ...(inst && inst.id === "peer" ? { remote: true } : {}),
+ ...(source != null ? { source } : {})
};
});
return { studies: per, requiresPatientLevel, aggregateOnly: per.filter((x) => x.mode === "aggregate-only").map((x) => x.study) };
@@ -471,6 +476,7 @@ export async function runAnalysis(template: AnalysisDoc | TemplateDoc, opts: Run
return {
study: docStudy, involvedStudies: involved, oncokbPin: pin,
+ sources: await describeSources(involved, resolvers),
// Echoed so a reader can see WHICH values produced these numbers, the same
// reason the OncoKB pin is echoed. `unusedParams` catches a rename that
// missed a reference: the run succeeds, so nothing else would say so.
diff --git a/lib/federation-remote.ts b/lib/federation-remote.ts
index ce70bb7d..cc5797af 100644
--- a/lib/federation-remote.ts
+++ b/lib/federation-remote.ts
@@ -19,7 +19,7 @@ import { loopbackOnlyConn, noteFailedQuery } from "./ch.ts";
import { logOutbound } from "./federation-log.ts";
import { PEER_TABLES, type RemotePatientRows, type PeerRowsRequest, type PeerRowsAnswer } from "./federation.ts";
import { acceptedFromPeers, allowedKinds, PATIENT_KIND_NAMES, type PatientKind } from "./disclosure.ts";
-import type { PatientTimeline, PatientEvent, PatientSample } from "./types.ts";
+import type { PatientTimeline, PatientEvent, PatientSample, PeerSource } from "./types.ts";
import type { StudyRow } from "./study-list.ts";
import type { StudyCatalog } from "./study-catalog.ts";
@@ -207,11 +207,25 @@ export class RemoteAggregateResolver implements AggregateCapable {
* aggregate SQL, marked as having executed on the peer rather than here.
*/
readonly queries: QueryLogEntry[] = [];
+ /** what the peer said about itself in its first answer, and when (lib/provenance.ts) */
+ private reported: PeerSource | null = null;
+ private answeredAt: string | null = null;
constructor(study: string, url: string, instance: Instance) {
this.study = study;
this.url = url;
this.instance = instance;
}
+ /** Every call to the peer goes through here, so its first self-report is kept. */
+ protected async call(name: string, args: unknown): Promise {
+ const r = await mcpCall(this.url, name, args);
+ const src = (r as { source?: PeerSource } | null)?.source;
+ if (src && !this.reported) { this.reported = src; this.answeredAt = new Date().toISOString(); }
+ return r;
+ }
+ /** Where this study's peer is, and what it reported about itself (null before it answered). */
+ provenance(): { url: string; reported: PeerSource | null; answeredAt: string | null } {
+ return { url: this.url, reported: this.reported, answeredAt: this.answeredAt };
+ }
private remember(r: { cohortSql?: string | null; queries?: QueryLogEntry[] | null }): void {
if (!r) return;
if (typeof r.cohortSql === "string" && r.cohortSql.trim()) this.peerSql = r.cohortSql;
@@ -232,54 +246,54 @@ export class RemoteAggregateResolver implements AggregateCapable {
}
async count(def: CohortDef | null | undefined): Promise {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {} });
+ const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {} });
this.remember(r);
return r.count;
}
async geneCounts(def: CohortDef | null | undefined, genes: string[]): Promise {
this.guard(def);
if (!genes.length) return [];
- const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {}, genes });
+ const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, genes });
this.remember(r);
return r.geneCounts;
}
async survivalCounts(def: CohortDef | null | undefined, endpoint = "OS", anchor?: SurvivalAnchor | null, columns?: EndpointColumns): Promise {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, survival: true, endpoint, ...(anchor ? { anchor } : {}), ...(columns ? { endpointColumns: columns } : {}) });
this.remember(r);
return r.survivalCounts;
}
async survivalEndpoints(): Promise {
// the peer reports what IT can answer; asking costs one call with no cohort
- const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: {}, survivalEndpoints: true })
+ const r = await this.call("federated_aggregates", { study: this.study, cohort: {}, survivalEndpoints: true })
.catch(() => ({ survivalEndpoints: [] as string[] }));
return (r as { survivalEndpoints?: string[] }).survivalEndpoints || [];
}
async timelineSpan(def: CohortDef | null | undefined): Promise {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, timelineSpan: true });
this.remember(r);
return r.timelineSpan || [];
}
async treatmentSequence(def: CohortDef | null | undefined, opts: SequenceOpts): Promise {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, sequence: opts });
this.remember(r);
return r.treatmentSequence || [];
}
async sequenceKeys(def: CohortDef | null | undefined, eventType: string): Promise<{ key: string; label: string; patients: number; values: number }[]> {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, sequenceKeys: eventType });
this.remember(r);
return r.sequenceKeys || [];
}
async eventCourse(def: CohortDef | null | undefined, opts: CourseOpts): Promise {
this.guard(def);
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, course: opts });
this.remember(r);
return r.eventCourse || [];
@@ -304,14 +318,14 @@ export class RemoteAggregateResolver implements AggregateCapable {
async pairCounts(def: CohortDef | null | undefined, pairs: [string, string][]): Promise {
this.guard(def);
if (!pairs.length) return [];
- const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {}, pairs });
+ const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, pairs });
this.remember(r);
return r.pairCounts || [];
}
async attributeStats(def: CohortDef | null | undefined, attributes: string[], opts?: { histograms?: boolean; topValues?: number }): Promise {
this.guard(def);
if (!attributes.length) return [];
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, attributes, ...(opts?.histograms ? { attributeHistograms: true } : {}),
...(opts?.topValues != null ? { attributeTopValues: opts.topValues } : {}) });
this.remember(r);
@@ -320,7 +334,7 @@ export class RemoteAggregateResolver implements AggregateCapable {
async profileMoments(def: CohortDef | null | undefined, genes: string[], profileType: string): Promise {
this.guard(def);
if (!genes.length || !profileType) return [];
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, momentGenes: genes, profileType });
this.remember(r);
return r.profileMoments || [];
@@ -328,7 +342,7 @@ export class RemoteAggregateResolver implements AggregateCapable {
async variantCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise {
this.guard(def);
if (!genes.length) return { variants: [], totals: [], minCount };
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, variantGenes: genes, minCount });
this.remember(r);
return r.variantCounts || { variants: [], totals: [], minCount };
@@ -336,7 +350,7 @@ export class RemoteAggregateResolver implements AggregateCapable {
async lollipopCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise {
this.guard(def);
if (!genes.length) return { changes: [], types: [], totals: [], cohortSamples: 0, minCount, annotated: false };
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, lollipopGenes: genes, minCount });
this.remember(r);
// a peer on an older build answers without these; refuse by name rather than draw nothing
@@ -347,7 +361,7 @@ export class RemoteAggregateResolver implements AggregateCapable {
async pairMoments(def: CohortDef | null | undefined, pairs: [string, string][], profileType: string): Promise {
this.guard(def);
if (!pairs.length || !profileType) return [];
- const r = await mcpCall(this.url, "federated_aggregates",
+ const r = await this.call("federated_aggregates",
{ study: this.study, cohort: def || {}, pairs, profileType });
this.remember(r);
return r.pairMoments || [];
@@ -401,7 +415,7 @@ export class RemotePatientResolver extends RemoteAggregateResolver implements Re
if (!this.patientKinds.has(kind))
throw new Error("this instance does not accept " + PATIENT_KIND_NAMES[kind] + " from the peer serving " + this.study + " (PEER_MCP_PATIENT_LEVEL)");
this.refuseIds(req.parts.map((p) => p.def));
- const r = await this.disclosed(mcpCall(this.peerUrl, "federated_patient_rows", {
+ const r = await this.disclosed(this.call("federated_patient_rows", {
study: this.study, table: req.table, parts: req.parts.map((p) => ({ cohort: p.cohort, cohortDef: p.def || {} })), source: this.instance.label,
query: req.query, defaults: req.defaults, mode: req.mode,
...(req.limit != null ? { limit: req.limit } : {}), ...(req.offset != null ? { offset: req.offset } : {}), ...(req.column ? { column: req.column } : {}),
@@ -432,8 +446,8 @@ export class RemoteTimelineResolver extends RemotePatientResolver {
async patientTimeline(patient: string): Promise {
if (!this.patientKinds.has("timeline"))
throw new Error("this instance does not accept patient timelines from the peer serving " + this.study + " (PEER_MCP_PATIENT_LEVEL)");
- const r = await this.disclosed(mcpCall<{ study: string; patient: string; events: unknown[]; samples: unknown[]; queries?: QueryLogEntry[] | null }>(
- this.peerUrl, "federated_patient_timeline", { study: this.study, patient }), "timeline");
+ const r = await this.disclosed(this.call<{ study: string; patient: string; events: unknown[]; samples: unknown[]; queries?: QueryLogEntry[] | null }>(
+ "federated_patient_timeline", { study: this.study, patient }), "timeline");
if (r.study !== this.study || r.patient !== patient) throw new Error("peer answered for " + r.study + "/" + r.patient + ", not " + this.study + "/" + patient);
if (Array.isArray(r.queries)) for (const q of r.queries) this.queries.push({ label: q.label, sql: q.sql, ms: q.ms, remote: true });
const events = (Array.isArray(r.events) ? r.events : []).map(asEvent);
diff --git a/lib/provenance.ts b/lib/provenance.ts
new file mode 100644
index 00000000..b36975e2
--- /dev/null
+++ b/lib/provenance.ts
@@ -0,0 +1,82 @@
+// Where an analysis's data came from. A peer says who it is in every federated answer (`source`:
+// its own name, the commit it runs, and when the study it answered for was last imported there);
+// the coordinator keeps the first such report per study (lib/federation-remote.ts) and lists every
+// source of a run, local or remote, in the result's `sources` (lib/analysis.ts).
+import { chQuery, isAbortError, type ChConn } from "./ch.ts";
+import { sq } from "./compiler.ts";
+import { BUILD_INFO } from "./build-info.ts";
+import { studyRouting, type Resolver } from "./federation.ts";
+import type { AnalysisSource, PeerSource } from "./types.ts";
+
+const IMPORTED_TTL = 5 * 60_000;
+const imported = new Map }>();
+
+/** When `study`'s data was last imported into the database `conn` reaches, or null if it can't tell. */
+export function studyImported(study: string, conn: ChConn): Promise {
+ const key = conn.id + "|" + conn.db + "|" + study;
+ const hit = imported.get(key);
+ if (hit && Date.now() - hit.at < IMPORTED_TTL) return hit.value;
+ const value = chQuery<{ d: string }>("SELECT toString(max(import_date)) AS d FROM " + conn.db + ".cancer_study WHERE cancer_study_identifier = " + sq(study), conn)
+ .then((r) => (r.data[0]?.d || "").replace(/\.0+$/, "") || null)
+ .catch((e) => { if (isAbortError(e)) throw e; return null; });
+ imported.set(key, { at: Date.now(), value });
+ value.catch(() => { if (imported.get(key)?.value === value) imported.delete(key); });
+ return value;
+}
+
+/** What this instance reports about itself when it answers as a peer, for `study` if one is named. */
+export async function peerSource(study?: string): Promise {
+ const conn = study ? (await studyRouting()).get(study) : undefined;
+ const base = { name: conn?.label || process.env.CLICKHOUSE_LABEL || "primary", build: BUILD_INFO.label };
+ return conn && study ? { ...base, study, imported: await studyImported(study, conn) } : base;
+}
+
+/** A remote resolver's view of its peer: where it is, and what the peer reported. */
+interface PeerProvenance { url: string; reported: PeerSource | null; answeredAt: string | null }
+const peerProvenance = (r: Resolver): PeerProvenance | null =>
+ typeof (r as unknown as { provenance?: unknown }).provenance === "function"
+ ? (r as unknown as { provenance(): PeerProvenance }).provenance() : null;
+
+/** The source a study's resolver stands for: one per database here, one per peer endpoint. */
+export function sourceKey(r: Resolver): string {
+ const peer = peerProvenance(r);
+ return peer ? "peer " + peer.url : "local " + r.instance.id;
+}
+
+/** Each study's place in describeSources' list, for the views' participation records to point at. */
+export function sourceIndex(studies: string[], resolvers: Record): Map {
+ const keys: string[] = [], out = new Map();
+ for (const study of studies) {
+ const r = resolvers[study];
+ if (!r) continue;
+ const key = sourceKey(r);
+ if (!keys.includes(key)) keys.push(key);
+ out.set(study, keys.indexOf(key));
+ }
+ return out;
+}
+
+/** Every source of a run, in the order its studies first appear, each with the studies it answered for. */
+export async function describeSources(studies: string[], resolvers: Record): Promise {
+ const out: AnalysisSource[] = [], byKey = new Map();
+ for (const study of studies) {
+ const r = resolvers[study];
+ if (!r) continue;
+ const key = sourceKey(r), peer = peerProvenance(r);
+ let src = byKey.get(key);
+ if (!src) {
+ // a peer's label also says what it shares; the participation records say that per view
+ const label = r.instance.label.replace(/ \(shares [^)]*\)$/, "").replace(/ \(aggregate-only\)$/, "");
+ src = peer
+ ? { label, remote: true, url: peer.url, ...(peer.reported ? { reportedName: peer.reported.name, build: peer.reported.build } : {}),
+ ...(peer.answeredAt ? { answeredAt: peer.answeredAt } : {}), studies: [] }
+ : { label, remote: false, build: BUILD_INFO.label, studies: [] };
+ byKey.set(key, src);
+ out.push(src);
+ }
+ const when = peer ? (peer.reported?.study === study ? peer.reported.imported ?? null : null)
+ : await studyImported(study, r.instance.conn);
+ src.studies.push({ study, imported: when });
+ }
+ return out;
+}
diff --git a/lib/result-text.ts b/lib/result-text.ts
index 43d23745..5eee2165 100644
--- a/lib/result-text.ts
+++ b/lib/result-text.ts
@@ -40,6 +40,11 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[]
out.push(`NOTE (tell the user): this document was written ${r.upgraded.from === "unversioned" ? "before documents carried a version" : "for DSL " + r.upgraded.from} and was upgraded to ${r.upgraded.to} to run` +
(r.upgraded.changes.length ? ": " + r.upgraded.changes.join("; ") : "") + ". Saving it writes the new version.");
out.push(`Analysis over ${r.involvedStudies.join(" + ")} — OncoKB pin ${r.oncokbPin || "none"} — ${r.stats.ms}ms.`);
+ // where the data came from, when a peer or more than one database answered
+ if (r.sources && (r.sources.length > 1 || r.sources.some((x) => x.remote)))
+ out.push("Data from: " + r.sources.map((x) => x.label + (x.remote ? " (over MCP at " + (x.url || "?") + (x.reportedName ? ", calls itself " + x.reportedName : "") +
+ (x.build ? ", build " + x.build : "") + ")" : " (local)") + " — " +
+ x.studies.map((st) => st.study + " imported " + (st.imported || "unknown")).join(", ")).join("; ") + ".");
// The document's own title and notes are the author's framing of the whole
// workspace. They were stored and rendered in the app but never reached a
// model over MCP, so a caller could not read back what it had written.
diff --git a/lib/types.ts b/lib/types.ts
index 555baae0..141d5b72 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1621,8 +1621,39 @@ export interface ClinicalRow {
* on: an aggregate-only participant returns counts and never an identifier, so a
* view built entirely from such participants moved no patient-level data at all.
*/
+/**
+ * One place an analysis's data came from: a database of this deployment, or a peer it reached
+ * over MCP, with the studies it answered for and what it reported about itself (lib/provenance.ts).
+ */
+export interface AnalysisSource {
+ /** as this deployment names it */
+ label: string;
+ /** a peer reached over MCP (the simulated one included) */
+ remote: boolean;
+ /** that peer's MCP endpoint */
+ url?: string;
+ /** what the peer calls itself, from its own answers — not this deployment's label for it */
+ reportedName?: string;
+ /** the commit the answering code runs, as it reports it */
+ build?: string;
+ /** when the peer first answered during this run */
+ answeredAt?: string;
+ /** each study it answered for, with when that study's data was last imported */
+ studies: { study: string; imported: string | null }[];
+}
+
+/** What a peer reports about itself in every federated answer (lib/provenance.ts). */
+export interface PeerSource {
+ name: string;
+ build: string;
+ /** the study answered for, and when its data was last imported there */
+ study?: string;
+ imported?: string | null;
+}
+
export interface ViewParticipation {
- studies: { study: string; mode: "patient-level" | "aggregate-only"; instance: string; remote?: boolean }[];
+ /** `source` indexes the result's `sources` */
+ studies: { study: string; mode: "patient-level" | "aggregate-only"; instance: string; remote?: boolean; source?: number }[];
/** true only for views whose output IS patients (the oncoprint's columns) */
requiresPatientLevel: boolean;
/** the studies that answered with aggregates only */
@@ -1798,6 +1829,8 @@ export interface CoursePoint {
}
export interface AnalysisResult {
study: string; involvedStudies: string[]; oncokbPin: string | null;
+ /** where the data came from: each database or MCP peer, and the studies it answered for */
+ sources?: AnalysisSource[];
/** the parameters this run resolved, and any declared but never referenced */
params?: Record;
unusedParams?: string[];
diff --git a/mcp/tools.ts b/mcp/tools.ts
index 09411ee5..70991ac1 100644
--- a/mcp/tools.ts
+++ b/mcp/tools.ts
@@ -22,6 +22,7 @@ import { allowedKinds, disclosedByThisInstance, PATIENT_KIND_NAMES, type Patient
import { checkQuery, cleanQuery, type QueryColumn, type RowSource, type TableQuery } from "../lib/table-query.ts";
import { buildExplore } from "../lib/explore.ts";
import { oncokbPin } from "../lib/annotation.ts";
+import { peerSource } from "../lib/provenance.ts";
import { connections, directConnections } from "../lib/ch.ts";
import { studiesFor } from "../lib/study-list.ts";
import { catalogFor } from "../lib/study-catalog.ts";
@@ -206,7 +207,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
(survivalCounts.length ? `, ${survivalCounts.length} survival strata` : "") +
(pairCounts.length ? `, ${pairCounts.length} gene-pair tables` : "") +
(attributeStats.length ? `, ${attributeStats.length} clinical attributes` : "") + " (no patient identifiers returned)" }],
- structuredContent: payload
+ structuredContent: { ...payload, source: await peerSource(s) }
};
} catch (e) {
return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true };
@@ -310,7 +311,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
const hideSql = process.env.FEDERATION_HIDE_SQL === "1";
return {
content: [{ type: "text" as const, text: `${table} rows for ${s} (${mode}) — patient-level, disclosed under MCP_DISCLOSE_PATIENTS (${PATIENT_KIND_NAMES[PEER_TABLES[table]]})` }],
- structuredContent: { table, ...out, ...(annotated ? { oncokbPin: pin } : {}), queries: hideSql ? null : res.queries }
+ structuredContent: { table, ...out, ...(annotated ? { oncokbPin: pin } : {}), queries: hideSql ? null : res.queries, source: await peerSource(s) }
};
} catch (e) { return rejected(e); }
});
@@ -331,7 +332,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
const hideSql = process.env.FEDERATION_HIDE_SQL === "1";
return {
content: [{ type: "text" as const, text: `timeline of ${patient} (${s}): ${t.events.length} events, ${t.samples.length} samples — patient-level, disclosed under MCP_DISCLOSE_PATIENTS` }],
- structuredContent: { study: s, patient, events: t.events, samples: t.samples, queries: hideSql ? null : res.queries }
+ structuredContent: { study: s, patient, events: t.events, samples: t.samples, queries: hideSql ? null : res.queries, source: await peerSource(s) }
};
} catch (e) { return rejected(e); }
});
@@ -365,7 +366,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
return {
content: [{ type: "text" as const, text: `facets for ${s}: ${facets.clinical.length} clinical attributes, ` +
`${facets.genes.length} genes, ${facets.events.length} event types (aggregates only)` }],
- structuredContent: facets as unknown as Record
+ structuredContent: { ...facets, source: await peerSource(s) } as unknown as Record
};
} catch (e) {
return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true };
@@ -389,7 +390,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
const studies = (await Promise.all(conns.map((c) => studiesFor(c)))).flat();
return {
content: [{ type: "text" as const, text: `${studies.length} studies held by this instance (counts only)` }],
- structuredContent: { studies }
+ structuredContent: { studies, source: await peerSource() }
};
} catch (e) {
return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true };
@@ -409,7 +410,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void {
const cat = await catalogFor(s);
return {
content: [{ type: "text" as const, text: `catalog for ${s}: ${cat.types.length} event types, ${cat.totalPatients} patients` }],
- structuredContent: cat as unknown as Record
+ structuredContent: { ...cat, source: await peerSource(s) } as unknown as Record
};
} catch (e) {
return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true };
diff --git a/test/provenance.test.ts b/test/provenance.test.ts
new file mode 100644
index 00000000..6125c686
--- /dev/null
+++ b/test/provenance.test.ts
@@ -0,0 +1,58 @@
+// Where an analysis's data came from (lib/provenance.ts): one source per database here and per
+// peer endpoint, each with the studies it answered for, and what a peer said about itself kept
+// apart from what this deployment calls it.
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { describeSources, peerSource, sourceIndex } from "../lib/provenance.ts";
+import type { Resolver } from "../lib/federation.ts";
+
+const local = (id: string, label: string) => ({
+ instance: { id, label, mode: "patient-level", conn: { id, label, db: "nodb", url: "", user: "", password: "" } }
+}) as unknown as Resolver;
+const remote = (url: string, label: string, study: string, reported: Record | null) => ({
+ instance: { id: "peer", label, mode: "aggregate-only" },
+ provenance: () => ({ url, reported: reported && { ...reported, study }, answeredAt: reported ? "2026-10-18T00:00:00.000Z" : null })
+}) as unknown as Resolver;
+
+describe("an analysis's sources", () => {
+ const resolvers = {
+ s1: local("a", "primary"),
+ s2: remote("http://peer/api/mcp", "GENIE (via MCP) (shares patient timelines)", "s2", { name: "GENIE", build: "abc1234", imported: "2026-07-27 21:21:00" }),
+ s3: remote("http://peer/api/mcp", "GENIE (via MCP)", "s3", { name: "GENIE", build: "abc1234", imported: null }),
+ s4: remote("http://other/api/mcp", "Other site", "s4", null),
+ };
+ const studies = ["s1", "s2", "s3", "s4"];
+
+ test("one source per database and per peer endpoint, in the order the studies first appear", async () => {
+ const src = await describeSources(studies, resolvers);
+ assert.deepEqual(src.map((s) => s.label), ["primary", "GENIE (via MCP)", "Other site"]);
+ assert.deepEqual(src.map((s) => s.studies.map((x) => x.study)), [["s1"], ["s2", "s3"], ["s4"]]);
+ assert.equal(src[0].remote, false);
+ assert.equal(src[0].studies[0].imported, null, "an import date that cannot be read is unknown, not invented");
+ });
+
+ test("a peer's own report is kept apart from this deployment's label for it", async () => {
+ const [, genie, other] = await describeSources(studies, resolvers);
+ assert.equal(genie.label, "GENIE (via MCP)", "the label, without what the peer shares (participation says that)");
+ assert.equal(genie.reportedName, "GENIE");
+ assert.equal(genie.url, "http://peer/api/mcp");
+ assert.equal(genie.build, "abc1234");
+ assert.equal(genie.answeredAt, "2026-10-18T00:00:00.000Z");
+ assert.deepEqual(genie.studies, [{ study: "s2", imported: "2026-07-27 21:21:00" }, { study: "s3", imported: null }]);
+ assert.equal(other.reportedName, undefined, "a peer that has not answered has not described itself");
+ assert.equal(other.remote, true);
+ });
+
+ test("each view's participation points at its study's source", async () => {
+ const src = await describeSources(studies, resolvers), idx = sourceIndex(studies, resolvers);
+ for (const s of studies) assert.ok(src[idx.get(s)!].studies.some((x) => x.study === s), s);
+ assert.deepEqual([...idx.values()], [0, 1, 1, 2]);
+ });
+
+ test("a peer describes itself: its name and its build", async () => {
+ const me = await peerSource();
+ assert.equal(typeof me.name, "string");
+ assert.ok(me.name.length > 0);
+ assert.equal(typeof me.build, "string");
+ });
+});