File size: 8,415 Bytes
6f91ce5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { z } from 'zod'
// The board service's client of the papers MCP (interfaces.md I5): paper records for quote checks
// (T02), paper records and seeds for the REST API (T08), and clusters for apply_clusters once
// discovery has cluster_papers. Records are cached in memory per paper ID; they rarely change.
export const DEFAULT_PAPERS_MCP_URL = 'http://127.0.0.1:8001/mcp'
const TIMEOUT_MS = 15_000
// clustering embeds papers the discovery service hasn't seen yet
const CLUSTER_TIMEOUT_MS = 120_000
const CACHE_MAX = 2000
// the tool list decides which canvas tools exist (I5), so it is checked on every canvas MCP request
const TOOLS_TTL_MS = 30_000
const FIRST_LIST_WAIT_MS = 3_000
/** `get_paper`'s record (discovery's Paper.to_record); fields are null when the sources don't have them. */
export interface PaperRecord {
paperId: string
ids?: Record<string, string>
title: string
authors?: string[]
year?: number | null
venue?: string | null
url?: string | null
abstract?: string | null
openAccessPdf?: string | null
type?: string | null
[key: string]: unknown
}
// `cluster_papers`' result (I5): communities over the board's papers, each named by discovery,
// and the k nearest neighbours per paper. `score` is Concept.md's older name for `similarity`.
const clusterResult = z.object({
clusters: z.array(
z.object({
clusterId: z.union([z.string().min(1), z.number()]).transform(String),
paperIds: z.array(z.string()),
label: z.string().nullish().transform((v) => v || null),
})
),
similarities: z
.array(
z
.object({ a: z.string(), b: z.string(), similarity: z.number().optional(), score: z.number().optional() })
.transform(({ a, b, similarity, score }) => ({ a, b, similarity: similarity ?? score ?? 0 }))
)
.default([]),
})
export type ClusterResult = z.infer<typeof clusterResult>
/** A seed to look up by its DOI, its PMID, or (for a PDF without a DOI) its title. */
export type SeedQuery = { doi: string } | { pmid: string } | { title: string }
/** Why a call failed: the papers MCP is down, it has no such paper, or it answered with something else. */
export class PapersError extends Error {
code: 'unreachable' | 'not_found' | 'bad_record'
constructor(code: PapersError['code'], message: string) {
super(message)
this.code = code
}
}
export interface PapersClient {
readonly url: string
/** Throws a PapersError whose message says why (unreachable, or the papers MCP's own error such as an unknown ID). */
getPaper(paperId: string): Promise<PaperRecord>
/** `resolve_seed`, for the REST seeds route (T08); stand-ins that only serve the canvas tools leave it out. */
resolveSeed?(seed: SeedQuery): Promise<PaperRecord>
/**
* Whether the papers MCP lists this tool, so canvas tools that need a planned discovery tool are
* hidden until it exists (I5). False while it is unreachable. Clients without it have none.
*/
hasTool?(name: string): Promise<boolean>
/** `cluster_papers`; only called when hasTool('cluster_papers'). Throws like getPaper. */
clusterPapers?(paperIds: string[]): Promise<ClusterResult>
close(): Promise<void>
}
interface ToolResult {
isError?: boolean
structuredContent?: Record<string, unknown>
content?: { type: string; text?: string }[]
}
const text = (r: ToolResult) => r.content?.find((c) => c.type === 'text')?.text ?? ''
// Python's MCP server returns a dict as structuredContent, and as JSON text for older clients;
// anything else comes wrapped as {result}.
function valueOf(result: ToolResult): Record<string, unknown> | null {
let value: unknown = result.structuredContent
if (!value) {
try {
value = JSON.parse(text(result))
} catch {
return null
}
}
const record = value as Record<string, unknown> | null
if (record && Object.keys(record).length === 1 && record.result && typeof record.result === 'object') {
return record.result as Record<string, unknown>
}
return record && typeof record === 'object' ? record : null
}
function recordOf(result: ToolResult): PaperRecord | null {
const record = valueOf(result)
return record && typeof record.paperId === 'string' ? (record as PaperRecord) : null
}
export function createPapersClient(url = process.env.PAPERS_MCP_URL || DEFAULT_PAPERS_MCP_URL): PapersClient {
const cache = new Map<string, PaperRecord>()
let client: Promise<Client> | null = null
const connect = () =>
(client ??= (async () => {
const c = new Client({ name: 'board-service', version: '0.1.0' })
await c.connect(new StreamableHTTPClientTransport(new URL(url)), { timeout: TIMEOUT_MS })
return c
})())
// a failed connection is dropped, so the next call tries again (discovery may restart)
async function close() {
const c = client
client = null
await c?.then((c) => c.close()).catch(() => {})
}
async function call(name: string, args: Record<string, unknown>, timeout: number): Promise<ToolResult> {
try {
const c = await connect()
return (await c.callTool({ name, arguments: args }, undefined, { timeout })) as ToolResult
} catch (e) {
await close()
throw new PapersError('unreachable', `The papers MCP at ${url} is unreachable (${e instanceof Error ? e.message : e})`)
}
}
// The tool list, refreshed in the background once it is older than TOOLS_TTL_MS; an
// unreachable papers MCP lists nothing until the next refresh.
let tools: { names: Set<string>; at: number } | null = null
let listing: Promise<Set<string>> | null = null
const listTools = () =>
(listing ??= (async () => {
let names = new Set<string>()
try {
const c = await connect()
names = new Set((await c.listTools(undefined, { timeout: TIMEOUT_MS })).tools.map((t) => t.name))
} catch {
await close()
}
tools = { names, at: Date.now() }
listing = null
return names
})())
// a tool that returns one paper record; `what` names the paper in errors: its ID, or the DOI or
// PMID it was looked up by
async function callForPaper(name: string, args: Record<string, string>, what: string): Promise<PaperRecord> {
const result = await call(name, args, TIMEOUT_MS)
if (result.isError) {
throw new PapersError('not_found', `The papers MCP has no record for ${what}: ${text(result) || 'unknown error'}`)
}
const record = recordOf(result)
if (!record) throw new PapersError('bad_record', `The papers MCP returned no paper record for ${what}`)
remember(record.paperId, record)
return record
}
function remember(paperId: string, record: PaperRecord) {
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!)
cache.set(paperId, record)
}
return {
url,
close,
async hasTool(name) {
if (!tools) {
// the first request waits a little for the list, never long: discovery may be down
const wait = new Promise<Set<string>>((resolve) => setTimeout(resolve, FIRST_LIST_WAIT_MS, new Set()).unref())
return (await Promise.race([listTools(), wait])).has(name)
}
if (Date.now() - tools.at > TOOLS_TTL_MS) void listTools()
return tools.names.has(name)
},
async clusterPapers(paperIds) {
const result = await call('cluster_papers', { paper_ids: paperIds }, CLUSTER_TIMEOUT_MS)
if (result.isError) throw new Error(`The papers MCP couldn't cluster the papers: ${text(result) || 'unknown error'}`)
const parsed = clusterResult.safeParse(valueOf(result))
if (!parsed.success) throw new Error('The papers MCP returned clusters in an unexpected shape')
return parsed.data
},
async getPaper(paperId) {
const hit = cache.get(paperId)
if (hit) return hit
const record = await callForPaper('get_paper', { paper_id: paperId }, paperId)
// the record may name the paper by another of its IDs
if (record.paperId !== paperId) remember(paperId, record)
return record
},
resolveSeed(seed) {
if ('doi' in seed) return callForPaper('resolve_seed', { doi: seed.doi }, seed.doi)
if ('pmid' in seed) return callForPaper('resolve_seed', { pmid: seed.pmid }, `PMID ${seed.pmid}`)
return callForPaper('resolve_seed', { title: seed.title }, `“${seed.title}”`)
},
}
}
let shared: PapersClient | undefined
/** The board service's one client, to PAPERS_MCP_URL. */
export const papersClient = () => (shared ??= createPapersClient())
|