Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/tools/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function registerAnalysisTools(server: McpServer) {
"",
"### Entities:",
...result.entities.map((e: FoundEntity) =>
`- ${e.id} -> ${e.mapsTo.map(m => `${m.identifier} (${m.resource})`).join(", ")}`
`- ${e.id} -> ${e.mapsTo.map(m => `${(m.ids ?? []).join("/")} (${m.resource})`).join(", ")}`
),
];

Expand Down
63 changes: 53 additions & 10 deletions src/tools/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,41 @@ import { z } from "zod";
import { contentClient } from "../clients/content.js";
import type { PhysicalEntity, Complex, ReferenceEntity, Event } from "../types/index.js";

/**
* `/data/participants/{id}` returns a *reduced* projection, not a full
* PhysicalEntity. Verified against the live Content Service:
*
* {"displayName": "...", "peDbId": 109581, "schemaClass": "Complex",
* "refEntities": [{"dbId":..., "identifier": "Q15628", ...}]}
*
* There is no `stId`, no `dbId` and no singular `referenceEntity` -- reading
* those printed "(undefined)" and silently dropped every external identifier.
*/
interface Participant {
dbId: number;
stId?: string;
peDbId: number;
displayName: string;
schemaClass: string;
referenceEntity?: ReferenceEntity;
refEntities?: ReferenceEntity[];
}

/**
* `/data/entity/{id}/componentOf` returns one entry per *relationship type*,
* each carrying parallel arrays of the containers reached by it:
*
* {"type": "hasEvent", "names": ["Programmed Cell Death"],
* "stIds": ["R-HSA-5357801"], "schemaClasses": ["TopLevelPathway"],
* "species": ["Homo sapiens"]}
*
* It is not a list of Complex objects -- there is no displayName/stId/
* schemaClass on an entry, so the old formatting printed "**undefined**
* (undefined) [undefined]" for every container.
*/
interface ComponentOfEntry {
type: string;
names?: string[];
stIds?: string[];
schemaClasses?: string[];
species?: string[];
}

interface EnhancedEntity extends PhysicalEntity {
Expand Down Expand Up @@ -159,17 +188,30 @@ export function registerEntityTools(server: McpServer) {
id: z.string().max(2048).describe("Entity stable ID or database ID"),
},
async ({ id }) => {
const containers = await contentClient.get<Complex[]>(`/data/entity/${encodeURIComponent(id)}/componentOf`);
const containers = await contentClient.get<ComponentOfEntry[]>(`/data/entity/${encodeURIComponent(id)}/componentOf`);

// Each entry holds several containers, so the count people care about is
// the flattened one, not the number of relationship types.
const total = containers.reduce((n, c) => n + (c.stIds?.length ?? 0), 0);

const lines = [
`## Structures Containing ${id}`,
`**Total:** ${containers.length}`,
`**Total:** ${total}`,
"",
...containers.slice(0, 50).map(c => `- **${c.displayName}** (${c.stId}) [${c.schemaClass}]`),
...containers.flatMap(c =>
// A componentOf entry is one *relationship* ("hasEvent",
// "hasComponent", ...) carrying parallel arrays of the containers
// reached by it -- names[i] pairs with stIds[i] and schemaClasses[i].
// There is no singular displayName/stId/schemaClass on the entry.
(c.stIds ?? []).map((stId, i) =>
`- **${c.names?.[i] ?? stId}** (${stId}) [${c.schemaClasses?.[i] ?? c.type}]`
)
),
];

if (containers.length > 50) {
lines.push(`... and ${containers.length - 50} more structures`);
if (total > 50) {
lines.splice(53);
lines.push(`... and ${total - 50} more structures`);
}

return {
Expand Down Expand Up @@ -205,8 +247,9 @@ export function registerEntityTools(server: McpServer) {
Object.entries(byType).forEach(([type, entities]) => {
lines.push(`### ${type} (${entities.length}):`);
entities.slice(0, 20).forEach(e => {
const refInfo = e.referenceEntity ? ` [${e.referenceEntity.identifier}]` : "";
lines.push(`- ${e.displayName} (${e.stId || e.dbId})${refInfo}`);
const ids = (e.refEntities ?? []).map(r => r.identifier).filter(Boolean);
const refInfo = ids.length > 0 ? ` [${ids.join(", ")}]` : "";
lines.push(`- ${e.displayName} (${e.peDbId})${refInfo}`);
});
if (entities.length > 20) {
lines.push(`... and ${entities.length - 20} more`);
Expand Down
132 changes: 72 additions & 60 deletions src/tools/interactors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,64 @@ interface PsicquicResource {
active: boolean;
}

interface InteractorSummary {
accession: string;
count: number;
/**
* All four interactor endpoints -- psicquic summary/details and static
* summary/details -- return the SAME envelope. Verified against the live
* Content Service:
*
* GET /interactors/static/molecule/P04637/details
* {"resource": "static",
* "entities": [{"acc": "P04637", "count": 249,
* "interactors": [{"acc": "Q00987", "alias": "MDM2",
* "score": 0.995, "evidences": 122}]}]}
*
* `entities` is the list of molecules that were *queried*, not the list of
* interactors -- those hang off each entity. The previous types flattened the
* two levels and used `accession` where the API says `acc`, so the summary
* tools printed "undefined" for both protein and count, and the details tools
* crashed on `e.score.toFixed` because `score` lives one level down.
*/
interface InteractorEnvelope {
resource: string;
entities?: InteractorEntity[];
}

interface InteractionDetails {
accession: string;
entities: InteractionEntity[];
interface InteractorEntity {
acc: string;
count: number;
interactors?: Interactor[];
}

interface InteractionEntity {
accession: string;
interface Interactor {
acc: string;
score: number;
interactorId?: number;
alias?: string;
evidences?: number;
id?: number;
}

interface StaticInteractionDetails {
accession: string;
interactsWith: StaticInteractor[];
/**
* Reduce the envelope to the single queried molecule. These tools always ask
* about one accession, so there is exactly one entity -- but the API still
* wraps it in an array, and an unknown accession yields an empty one.
*/
function firstEntity(result: InteractorEnvelope): InteractorEntity | undefined {
return result.entities?.[0];
}

interface StaticInteractor {
accession: string;
score: number;
chemicalId?: string;
function formatInteractors(interactors: Interactor[], limit = 30): string[] {
const lines = [...interactors]
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.slice(0, limit)
.map(i => {
const score = typeof i.score === "number" ? i.score.toFixed(3) : "n/a";
return `- **${i.acc}** (score: ${score})${i.alias ? ` - ${i.alias}` : ""}`;
});

if (interactors.length > limit) {
lines.push(`... and ${interactors.length - limit} more interactors`);
}
return lines;
}

export function registerInteractorTools(server: McpServer) {
Expand Down Expand Up @@ -77,15 +109,16 @@ export function registerInteractorTools(server: McpServer) {
accession: z.string().max(2048).describe("Protein accession (e.g., UniProt ID)"),
},
async ({ resource, accession }) => {
const result = await contentClient.get<InteractorSummary>(
const result = await contentClient.get<InteractorEnvelope>(
`/interactors/psicquic/molecule/${encodeURIComponent(resource)}/${encodeURIComponent(accession)}/summary`
);
const entity = firstEntity(result);

const lines = [
`## PSICQUIC Interaction Summary`,
`**Protein:** ${result.accession}`,
`**Protein:** ${entity?.acc ?? accession}`,
`**Resource:** ${resource}`,
`**Interaction count:** ${result.count}`,
`**Interaction count:** ${entity?.count ?? 0}`,
];

return {
Expand All @@ -103,24 +136,23 @@ export function registerInteractorTools(server: McpServer) {
accession: z.string().max(2048).describe("Protein accession"),
},
async ({ resource, accession }) => {
const result = await contentClient.get<InteractionDetails>(
const result = await contentClient.get<InteractorEnvelope>(
`/interactors/psicquic/molecule/${encodeURIComponent(resource)}/${encodeURIComponent(accession)}/details`
);
const entity = firstEntity(result);
const interactors = entity?.interactors ?? [];

const lines = [
`## PSICQUIC Interactions for ${result.accession}`,
`## PSICQUIC Interactions for ${entity?.acc ?? accession}`,
`**Resource:** ${resource}`,
`**Interactors found:** ${result.entities.length}`,
`**Interactors found:** ${interactors.length}`,
"",
"### Interacting Proteins (sorted by score):",
...result.entities
.sort((a, b) => b.score - a.score)
.slice(0, 30)
.map(e => `- **${e.accession}** (score: ${e.score.toFixed(3)})${e.alias ? ` - ${e.alias}` : ""}`),
];

if (result.entities.length > 30) {
lines.push(`... and ${result.entities.length - 30} more interactors`);
if (interactors.length > 0) {
lines.push("### Interacting Proteins (sorted by score):", ...formatInteractors(interactors));
} else {
lines.push(`*No interactions found in ${resource}.*`);
}

return {
Expand All @@ -137,36 +169,20 @@ export function registerInteractorTools(server: McpServer) {
accession: z.string().max(2048).describe("Protein accession (e.g., UniProt ID)"),
},
async ({ accession }) => {
interface StaticDetailsResult {
accession: string;
entities: Array<{
acc: string;
score: number;
}>;
}

const result = await contentClient.get<StaticDetailsResult>(
const result = await contentClient.get<InteractorEnvelope>(
`/interactors/static/molecule/${encodeURIComponent(accession)}/details`
);
const entity = firstEntity(result);
const interactors = entity?.interactors ?? [];

const lines = [
`## Static Interactors for ${result.accession}`,
`**Interactors found:** ${result.entities?.length || 0}`,
`## Static Interactors for ${entity?.acc ?? accession}`,
`**Interactors found:** ${interactors.length}`,
"",
];

if (result.entities && result.entities.length > 0) {
lines.push("### Interacting Proteins:");
result.entities
.sort((a, b) => b.score - a.score)
.slice(0, 30)
.forEach(e => {
lines.push(`- **${e.acc}** (score: ${e.score.toFixed(3)})`);
});

if (result.entities.length > 30) {
lines.push(`... and ${result.entities.length - 30} more interactors`);
}
if (interactors.length > 0) {
lines.push("### Interacting Proteins:", ...formatInteractors(interactors));
} else {
lines.push("*No interactors found in the static database.*");
}
Expand Down Expand Up @@ -214,18 +230,14 @@ export function registerInteractorTools(server: McpServer) {
accession: z.string().max(2048).describe("Protein accession"),
},
async ({ accession }) => {
interface SummaryResult {
accession: string;
count: number;
}

const result = await contentClient.get<SummaryResult>(
const result = await contentClient.get<InteractorEnvelope>(
`/interactors/static/molecule/${encodeURIComponent(accession)}/summary`
);
const entity = firstEntity(result);

const lines = [
`## Interactor Summary for ${result.accession}`,
`**Total interactions:** ${result.count}`,
`## Interactor Summary for ${entity?.acc ?? accession}`,
`**Total interactions:** ${entity?.count ?? 0}`,
];

return {
Expand Down
6 changes: 5 additions & 1 deletion src/tools/pathway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,11 @@ export function registerPathwayTools(server: McpServer) {
"reactome_events_hierarchy",
"Get the complete event hierarchy (pathways and reactions tree) for a species. Warning: This returns a large data structure.",
{
species: z.string().max(2048).optional().default("Homo sapiens").describe("Species name or taxonomy ID"),
// Defaults to the taxonomy ID, not the name: /data/eventsHierarchy
// returns HTTP 500 for "Homo sapiens" but 200 for "9606", so the
// previous default made this tool fail every time it was called without
// an explicit species.
species: z.string().max(2048).optional().default("9606").describe("Species taxonomy ID (e.g. 9606). Names are accepted by the API but are unreliable here -- prefer the ID."),
},
async ({ species }) => {
const hierarchy = await contentClient.get<EventHierarchy[]>(`/data/eventsHierarchy/${encodeURIComponent(species)}`);
Expand Down
Loading