Add WebMCP integration

Add getting individuals and focusing the view on a selected person.

This feature was added following the method described in the article
"Elephants, Goldfish and the New Golden Age of Software Engineering"
by Dave Rensin
https://drensin.medium.com/elephants-goldfish-and-the-new-golden-age-of-software-engineering-c33641a48874

 #vibecoded
This commit is contained in:
Przemek Więch
2026-04-18 21:36:08 +02:00
parent a2549ebc18
commit 743204ade6
8 changed files with 1087 additions and 0 deletions

View File

@@ -49,6 +49,7 @@ import {SidePanel} from './sidepanel/side-panel';
import {analyticsEvent} from './util/analytics';
import {getI18nMessage} from './util/error_i18n';
import {idToIndiMap, TopolaData} from './util/gedcom_util';
import {WebMcpBridge} from './webmcp';
/**
* Load GEDCOM URL from VITE_STATIC_URL environment variable.
@@ -246,6 +247,7 @@ export function App() {
/** Freeze animations after initial chart render. */
const [freezeAnimation, setFreezeAnimation] = useState(false);
const [config, setConfig] = useState(DEFALUT_CONFIG);
const [mcpBridge] = useState(() => new WebMcpBridge());
const intl = useIntl();
const navigate = useNavigate();
@@ -437,6 +439,27 @@ export function App() {
})();
});
useEffect(() => {
mcpBridge.registerTools();
return () => {
mcpBridge.unregisterTools();
};
}, [mcpBridge]);
useEffect(() => {
mcpBridge.setData(data || null);
}, [data, mcpBridge]);
useEffect(() => {
mcpBridge.setDetailIndi(detailIndi || null);
}, [detailIndi, mcpBridge]);
useEffect(() => {
mcpBridge.setSetSelectionCallback((id: string) => {
onSelection({id, generation: 0});
});
}, [mcpBridge]);
function updateUrl(args: queryString.ParsedQuery<any>) {
const search = queryString.parse(location.search);
for (const key in args) {

View File

@@ -139,3 +139,51 @@ describe('getName()', () => {
expect(getName(person)).toBe('Only Name');
});
});
import {
findRelationshipPath,
getAncestors,
getDescendants,
idToFamMap,
idToIndiMap,
} from './gedcom_util';
describe('Relationship algorithms', () => {
const sampleData = {
indis: [
{id: 'I1', fams: ['F1']},
{id: 'I2', fams: ['F1'], famc: 'F2'},
{id: 'I3', famc: 'F1'},
{id: 'I4', famc: 'F2'},
],
fams: [
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
{id: 'F2', children: ['I2', 'I4']},
],
} as any;
const indiMap = idToIndiMap(sampleData);
const famMap = idToFamMap(sampleData);
it('findRelationshipPath finds direct paths', () => {
const path = findRelationshipPath('I1', 'I3', indiMap, famMap);
expect(path).toEqual(['I1', 'I3']);
});
it('findRelationshipPath finds sibling paths', () => {
const path = findRelationshipPath('I2', 'I4', indiMap, famMap);
expect(path).toEqual(['I2', 'I4']);
});
it('getAncestors respects generations bounds', () => {
const ancestors = getAncestors('I3', 1, indiMap, famMap);
expect(ancestors).toContain('I1');
expect(ancestors).toContain('I2');
expect(ancestors).not.toContain('I4');
});
it('getDescendants respects generations bounds', () => {
const descendants = getDescendants('I2', 1, indiMap, famMap);
expect(descendants).toContain('I3');
});
});

View File

@@ -43,6 +43,7 @@ export function pointerToId(pointer: string): string {
return pointer.substring(1, pointer.length - 1);
}
/** Returns a map from individual ID to individual data object. */
export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
const map = new Map<string, JsonIndi>();
data.indis.forEach((indi) => {
@@ -51,6 +52,7 @@ export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
return map;
}
/** Returns a map from family ID to family data object. */
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
const map = new Map<string, JsonFam>();
data.fams.forEach((fam) => {
@@ -276,6 +278,7 @@ export function convertGedcom(
};
}
/** Returns the name of the software used to generate the GEDCOM file, if available. */
export function getSoftware(head: GedcomEntry): string | null {
const sour =
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
@@ -284,6 +287,7 @@ export function getSoftware(head: GedcomEntry): string | null {
return (name && name.data) || null;
}
/** Returns the name of an individual, preferring birth name over married name. */
export function getName(person: GedcomEntry): string | undefined {
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
const notMarriedName = names.find(
@@ -296,6 +300,7 @@ export function getName(person: GedcomEntry): string | undefined {
return name?.data.replace(/\//g, '');
}
/** Returns the file name for a media entry, combining title and extension. */
export function getFileName(fileEntry: GedcomEntry): string | undefined {
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
@@ -316,26 +321,31 @@ function findFileEntry(
);
}
/** Returns the first non-image file entry for a media object. */
export function getNonImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
}
/** Returns the first image file entry for a media object. */
export function getImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
}
/** Resolves the DATE sub-entry for the given GEDCOM entry. */
export function resolveDate(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
}
/** Resolves the TYPE sub-entry data for the given GEDCOM entry. */
export function resolveType(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
}
/** Converts a GEDCOM source reference entry to a structured Source object. */
export function mapToSource(
sourceEntryReference: GedcomEntry,
gedcom: GedcomData,
@@ -374,3 +384,162 @@ export function mapToSource(
publicationInfo: publicationInfo?.data,
};
}
/** Finds the shortest relationship path between two individuals in the family tree. */
export function findRelationshipPath(
indiId1: string,
indiId2: string,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const getNeighbors = (id: string): string[] => {
const indi = indiMap.get(id);
if (!indi) {
return [];
}
const neighbors: string[] = [];
if (indi.famc) {
const fam = famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
neighbors.push(fam.wife);
}
if (fam.husb) {
neighbors.push(fam.husb);
}
if (fam.children) {
fam.children.forEach((child) => {
if (child !== id) {
neighbors.push(child);
}
});
}
}
}
if (indi.fams) {
indi.fams.forEach((famId) => {
const fam = famMap.get(famId);
if (fam) {
if (fam.wife && fam.wife !== id) {
neighbors.push(fam.wife);
}
if (fam.husb && fam.husb !== id) {
neighbors.push(fam.husb);
}
if (fam.children) {
fam.children.forEach((child) => neighbors.push(child));
}
}
});
}
return neighbors.filter((nId) => !nId.startsWith('private_'));
};
const queue: string[] = [indiId1];
const visited = new Map<string, string | null>();
visited.set(indiId1, null);
while (queue.length > 0) {
const current = queue.shift()!;
if (current === indiId2) {
const path: string[] = [];
let curr: string | null = current;
while (curr !== null) {
path.push(curr);
curr = visited.get(curr) || null;
}
return path.reverse();
}
const neighbors = getNeighbors(current);
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.set(neighbor, current);
queue.push(neighbor);
}
}
}
return [];
}
/** Returns the ancestors of an individual up to a specified number of generations. */
export function getAncestors(
indiId: string,
generations: number,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const result: string[] = [];
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
const visited = new Set<string>();
visited.add(indiId);
while (queue.length > 0) {
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);
}
if (gen < generations) {
const indi = indiMap.get(id);
if (indi && indi.famc) {
const fam = famMap.get(indi.famc);
if (fam) {
if (fam.wife && !visited.has(fam.wife)) {
visited.add(fam.wife);
queue.push({id: fam.wife, gen: gen + 1});
}
if (fam.husb && !visited.has(fam.husb)) {
visited.add(fam.husb);
queue.push({id: fam.husb, gen: gen + 1});
}
}
}
}
}
return result;
}
/** Returns the descendants of an individual up to a specified number of generations. */
export function getDescendants(
indiId: string,
generations: number,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const result: string[] = [];
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
const visited = new Set<string>();
visited.add(indiId);
while (queue.length > 0) {
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);
}
if (gen < generations) {
const indi = indiMap.get(id);
if (indi && indi.fams) {
indi.fams.forEach((famId) => {
const fam = famMap.get(famId);
if (fam && fam.children) {
fam.children.forEach((child) => {
if (!visited.has(child)) {
visited.add(child);
queue.push({id: child, gen: gen + 1});
}
});
}
});
}
}
}
return result;
}

383
src/webmcp.ts Normal file
View File

@@ -0,0 +1,383 @@
import {JsonEvent, JsonFam, JsonGedcomData, JsonIndi} from 'topola';
import {buildSearchIndex, SearchIndex, SearchResult} from './menu/search_index';
import {
findRelationshipPath,
getAncestors,
getDescendants,
idToFamMap,
idToIndiMap,
TopolaData,
} from './util/gedcom_util';
import {WEBMCP_TOOLS} from './webmcp_definitions';
import './webmcp_types';
// Maximum generational lookup depth exposed to the assistant to maintain response latency.
const MAX_GENERATIONS = 5;
// Maximum search results returned to the assistant to maintain response latency.
const MAX_SEARCH_RESULTS = 10;
interface IndiReference {
id: string;
name: string;
}
interface DatePlace {
date?: string;
place?: string;
}
interface BasicIndi {
id: string;
name: string;
birth?: DatePlace;
death?: DatePlace;
mother: IndiReference | null;
father: IndiReference | null;
}
interface SpouseInfo {
spouse: BasicIndi | null;
marriage?: DatePlace;
}
interface FullIndi {
id: string;
name: string;
birth?: DatePlace;
death?: DatePlace;
mother: BasicIndi | null;
father: BasicIndi | null;
children?: BasicIndi[];
spouses?: SpouseInfo[];
}
function toMcpResponse(data: unknown) {
return {
content: [{type: 'text', text: JSON.stringify(data)}],
structuredContent: data,
};
}
function textMcpResponse(text: string) {
return {
content: [{type: 'text', text}],
};
}
export class WebMcpBridge {
private detailIndi: string | null = null;
private searchIndex: SearchIndex | null = null;
private chartData: JsonGedcomData | null = null;
private indiMap: Map<string, JsonIndi> = new Map();
private famMap: Map<string, JsonFam> = new Map();
private setSelectionCallback: ((id: string) => void) | null = null;
private toolsRegistered = false;
/** Returns the full details of the currently selected person. */
private async handleGetSelectedPerson() {
const detailIndi = this.detailIndi;
if (!detailIndi || detailIndi.startsWith('private_')) {
return textMcpResponse('No person is currently selected.');
}
return toMcpResponse(this.toFullIndi(detailIndi));
}
private async handleSearchIndi(params: {query: string}) {
if (!this.searchIndex && this.chartData) {
this.searchIndex = buildSearchIndex(this.chartData);
}
const index = this.searchIndex;
if (!index) {
return textMcpResponse('Data not loaded.');
}
const results = index.search(params.query);
const basicIndis = results
.slice(0, MAX_SEARCH_RESULTS)
.map((r: SearchResult) => this.toBasicIndi(r.id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleInspectIndi(params: {id: string}) {
const result = this.toFullIndi(params.id);
if (!result) {
return textMcpResponse(`No person found with id ${params.id}.`);
}
return toMcpResponse(result);
}
private async handleFocusIndi(params: {id: string}) {
if (params.id.startsWith('private_')) {
return textMcpResponse(`No person found with id ${params.id}.`);
}
const callback = this.setSelectionCallback;
if (!callback) {
return textMcpResponse('Error shifting viewport.');
}
callback(params.id);
return toMcpResponse({status: 'success'});
}
private async handleFindRelationshipPath(params: {
source: string;
target: string;
}) {
const pathIds = findRelationshipPath(
params.source,
params.target,
this.indiMap,
this.famMap,
);
const basicIndis = pathIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleGetAncestors(params: {id: string; generations: number}) {
const ceiling = Math.min(params.generations ?? 3, MAX_GENERATIONS);
const ancestorIds = getAncestors(
params.id,
ceiling,
this.indiMap,
this.famMap,
);
const basicIndis = ancestorIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleGetDescendants(params: {
id: string;
generations: number;
}) {
const ceiling = Math.min(params.generations ?? 3, MAX_GENERATIONS);
const descendantIds = getDescendants(
params.id,
ceiling,
this.indiMap,
this.famMap,
);
const basicIndis = descendantIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
/** Updates the currently selected individual in focus. */
public setDetailIndi(newDetailIndi: string | null): void {
this.detailIndi = newDetailIndi;
}
/** Updates internal dataset and rebuilds search indexes and ID maps. */
public setData(newData: TopolaData | null): void {
if (newData) {
this.indiMap = idToIndiMap(newData.chartData);
this.famMap = idToFamMap(newData.chartData);
this.chartData = newData.chartData;
this.searchIndex = null;
} else {
this.indiMap.clear();
this.famMap.clear();
this.chartData = null;
this.searchIndex = null;
}
}
/** Attaches standard viewport control callback for tool handlers. */
public setSetSelectionCallback(callback: (id: string) => void): void {
this.setSelectionCallback = callback;
}
private getIndiName(indiId: string): string {
const indi = this.indiMap.get(indiId);
if (!indi) {
return 'Unknown';
}
return (
[indi.firstName, indi.lastName].filter(Boolean).join(' ') || 'Unknown'
);
}
private getIndiReference(indiId: string): IndiReference {
return {
id: indiId,
name: this.getIndiName(indiId),
};
}
private getEvent(event: JsonEvent | undefined): DatePlace | undefined {
if (!event) {
return undefined;
}
const parts: string[] = [];
if (event.date) {
const d = event.date;
if (d.day || d.month || d.year) {
parts.push([d.day, d.month, d.year].filter(Boolean).join('-'));
} else if (d.text) {
parts.push(d.text);
}
}
return {
date: parts.join(' ') || undefined,
place: event.place,
};
}
private toBasicIndi(indiId: string): BasicIndi | null {
if (indiId.startsWith('private_')) {
return null;
}
const indi = this.indiMap.get(indiId);
if (!indi) {
return null;
}
let mother = null;
let father = null;
if (indi.famc) {
const fam = this.famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
mother = this.getIndiReference(fam.wife);
}
if (fam.husb) {
father = this.getIndiReference(fam.husb);
}
}
}
return {
id: indi.id,
name: this.getIndiName(indi.id),
birth: this.getEvent(indi.birth),
death: this.getEvent(indi.death),
mother,
father,
};
}
private toFullIndi(indiId: string): FullIndi | null {
if (indiId.startsWith('private_')) {
return null;
}
const indi = this.indiMap.get(indiId);
if (!indi) {
return null;
}
let mother = null;
let father = null;
if (indi.famc) {
const fam = this.famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
mother = this.toBasicIndi(fam.wife);
}
if (fam.husb) {
father = this.toBasicIndi(fam.husb);
}
}
}
const children: BasicIndi[] = [];
const spouses: SpouseInfo[] = [];
if (indi.fams) {
indi.fams.forEach((famId) => {
const fam = this.famMap.get(famId);
if (fam) {
const spouseId = fam.wife === indiId ? fam.husb : fam.wife;
if (spouseId) {
spouses.push({
spouse: this.toBasicIndi(spouseId),
marriage: this.getEvent(fam.marriage),
});
}
if (fam.children) {
fam.children.forEach((childId) => {
const child = this.toBasicIndi(childId);
if (child) {
children.push(child);
}
});
}
}
});
}
return {
id: indi.id,
name: this.getIndiName(indi.id),
birth: this.getEvent(indi.birth),
death: this.getEvent(indi.death),
mother,
father,
children,
spouses,
};
}
/** Registers standard tools for the LLM research copilot features. */
public registerTools(): void {
if (this.toolsRegistered || !navigator.modelContext) {
return;
}
const modelContext = navigator.modelContext;
const implementations = {
get_selected_person: () => this.handleGetSelectedPerson(),
search_indi: (params: {query: string}) => this.handleSearchIndi(params),
inspect_indi: (params: {id: string}) => this.handleInspectIndi(params),
focus_indi: (params: {id: string}) => this.handleFocusIndi(params),
find_relationship_path: (params: {source: string; target: string}) =>
this.handleFindRelationshipPath(params),
get_ancestors: (params: {id: string; generations: number}) =>
this.handleGetAncestors(params),
get_descendants: (params: {id: string; generations: number}) =>
this.handleGetDescendants(params),
};
WEBMCP_TOOLS.forEach((toolDef) => {
const execute = (
implementations as Record<string, (p: any) => Promise<unknown>>
)[toolDef.name];
if (execute) {
modelContext.registerTool({
...toolDef,
execute: execute as (
params: Record<string, unknown>,
) => Promise<unknown>,
});
}
});
this.toolsRegistered = true;
}
/** Unregisters tools to prevent collision side-effects and cleanup. */
public unregisterTools(): void {
if (!this.toolsRegistered || !navigator.modelContext) {
return;
}
const modelContext = navigator.modelContext;
const unregister = modelContext.unregisterTool;
if (typeof unregister === 'function') {
WEBMCP_TOOLS.forEach((toolDef) => {
try {
unregister(toolDef.name);
} catch (e) {
/* ignore */
}
});
}
this.toolsRegistered = false;
}
}

94
src/webmcp_definitions.ts Normal file
View File

@@ -0,0 +1,94 @@
import {ToolDefinition} from './webmcp_types';
export const WEBMCP_TOOLS: ToolDefinition[] = [
{
name: 'get_selected_person',
description:
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
inputSchema: {type: 'object', properties: {}},
},
{
name: 'search_indi',
description: 'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
inputSchema: {
type: 'object',
properties: {
query: {type: 'string', description: 'The name to search for.'},
},
required: ['query'],
},
},
{
name: 'inspect_indi',
description:
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID of the individual.'},
},
required: ['id'],
},
},
{
name: 'focus_indi',
description:
'Instructs the Topola viewer camera view to center on and focus a specific person. Restructures the tree view to show ancestors and descendants of the selected person.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID to focus.'},
},
required: ['id'],
},
},
{
name: 'find_relationship_path',
description:
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
inputSchema: {
type: 'object',
properties: {
source: {type: 'string', description: 'Start individual ID'},
target: {type: 'string', description: 'End individual ID'},
},
required: ['source', 'target'],
},
},
{
name: 'get_ancestors',
description: 'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
},
required: ['id'],
},
},
{
name: 'get_descendants',
description: 'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
},
required: ['id'],
},
},
];

24
src/webmcp_types.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, unknown>;
required?: string[];
};
}
export interface WebMcpTool extends ToolDefinition {
execute: (params: Record<string, unknown>) => Promise<unknown>;
}
export interface ModelContext {
registerTool(tool: WebMcpTool): void;
unregisterTool?(name: string): void;
}
declare global {
interface Navigator {
modelContext?: ModelContext;
}
}