// The REST and SSE API over HTTP, beside the canvas MCP, with a stand-in papers MCP: // node --test server/api.test.ts import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import assert from 'node:assert/strict' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { after, before, test } from 'node:test' import { z } from 'zod' import { paperShapeId } from '../shared/ids.ts' import { defaultPaperCardProps, type PaperCardShape } from '../shared/shapes.ts' import { handleApi } from './api.ts' import { closeBoardDb, setBoardDbPath } from './boardDb.ts' import { handleCanvasMcp } from './canvasMcp.ts' import { streamCount } from './events.ts' import { textPdf } from './fixtures/textPdf.ts' import { createPapersClient, type PapersClient } from './papersClient.ts' import { setPdfDir } from './pdfProxy.ts' import { setUploadsDir } from './pdfSeed.ts' import { closeAllRooms, putShapes, readRoom, setRoomsDir } from './rooms.ts' const dir = mkdtempSync(join(tmpdir(), 'board-api-')) setRoomsDir(join(dir, 'rooms')) setBoardDbPath(join(dir, 'board.db')) setPdfDir(join(dir, 'pdfs')) setUploadsDir(join(dir, 'uploads')) // like discovery's papers MCP: records as structured content, unknown papers as tool errors const TIROSH = { paperId: 'pmid:27124452', ids: { pmid: '27124452', doi: '10.1126/science.aad0501' }, title: 'Dissecting the multicellular ecosystem of metastatic melanoma by single-cell RNA-seq', authors: ['Itay Tirosh', 'Benjamin Izar'], year: 2016, venue: 'Science', url: 'https://doi.org/10.1126/science.aad0501', abstract: 'To explore the distinct genotypic and phenotypic states of melanoma tumors, we applied single-cell RNA-seq.', openAccessPdf: 'http://127.0.0.1:9/tirosh.pdf', type: 'journal-article', } const SADE = { paperId: 'pmid:30388456', ids: { pmid: '30388456', doi: '10.1016/j.cell.2018.10.038' }, title: 'Defining T cell states associated with response to checkpoint immunotherapy in melanoma', year: 2018, } const records: Record = { [TIROSH.paperId]: TIROSH, [SADE.paperId]: SADE, 'pmid:2': { paperId: 'pmid:2', title: 'No PDF', openAccessPdf: null }, } function papersServer() { const server = new McpServer({ name: 'papers', version: '0' }) const reply = (record: object | undefined, missing: string) => record ? { structuredContent: record as Record, content: [{ type: 'text' as const, text: JSON.stringify(record) }] } : { isError: true, content: [{ type: 'text' as const, text: missing }] } server.registerTool('get_paper', { inputSchema: { paper_id: z.string() } }, async ({ paper_id }) => reply(records[paper_id], `Unknown paper ID ${paper_id}`) ) server.registerTool( 'resolve_seed', { inputSchema: { doi: z.string().optional(), pmid: z.string().optional(), title: z.string().optional() } }, async ({ doi, pmid, title }) => reply( Object.values(records).find((r) => (title ? (r as { title: string }).title === title : JSON.stringify(r).includes(`"${pmid ?? doi}"`))), `No paper for ${pmid ?? doi ?? title}` ) ) return server } let mcpHttp: Server let http: Server let base = '' let papers: PapersClient before(async () => { mcpHttp = createServer(async (req, res) => { const server = papersServer() const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }) res.on('close', () => server.close()) await server.connect(transport) await transport.handleRequest(req, res) }) await new Promise((resolve) => mcpHttp.listen(0, '127.0.0.1', resolve)) papers = createPapersClient(`http://127.0.0.1:${(mcpHttp.address() as AddressInfo).port}/mcp`) http = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost') const canvas = url.pathname.match(/^\/api\/mcp\/canvas\/([^/]+)$/) if (canvas) handleCanvasMcp(req, res, canvas[1], papers) else handleApi(req, res, url, papers) }) await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)) base = `http://127.0.0.1:${(http.address() as AddressInfo).port}` }) after(async () => { await papers.close() for (const server of [http, mcpHttp]) { server.closeAllConnections() await new Promise((resolve) => server.close(resolve)) } closeAllRooms() closeBoardDb() rmSync(dir, { recursive: true, force: true }) }) interface Options { member?: string | null body?: unknown headers?: Record } async function call(method: string, path: string, { member = 'm_ana', body, headers = {} }: Options = {}) { const res = await fetch(`${base}/api/v1${path}`, { method, headers: { ...(member ? { authorization: `Bearer ${member}` } : {}), ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers, }, body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), }) const text = await res.text() return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : undefined } } const vote = (board: string, paperId: string, member: string, decision: string | null) => call('PUT', `/boards/${board}/papers/${encodeURIComponent(paperId)}/vote`, { member, body: { decision } }) let boards = 0 /** A board whose room already has paper cards, as an agent made them. */ function roomWith(...paperIds: string[]) { const board = `room${++boards}` putShapes( board, paperIds.map((paperId, i) => ({ id: paperShapeId(paperId), type: 'paper-card', x: i * 100, y: 0, props: defaultPaperCardProps(paperId, { title: `Paper ${paperId}`, kind: i === 0 && paperId.startsWith('seed') ? 'seed' : 'suggestion' }), meta: { createdBy: 'agent', onBehalfOf: 'm_ana', runId: null, paperId }, })) ) return board } const cardOf = (board: string, paperId: string) => readRoom(board, (txn) => txn.get(paperShapeId(paperId))) as PaperCardShape /** Reads an SSE response frame by frame. */ async function listen(path: string, headers: Record = {}) { const abort = new AbortController() const res = await fetch(`${base}/api/v1${path}`, { headers, signal: abort.signal }) const frames: { id: number; event: string; data: any }[] = [] const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' void (async () => { try { for (let read = await reader.read(); !read.done; read = await reader.read()) { buffer += decoder.decode(read.value, { stream: true }) for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) { const fields = Object.fromEntries( buffer .slice(0, end) .split('\n') .filter((l) => !l.startsWith(':')) .map((l) => [l.slice(0, l.indexOf(': ')), l.slice(l.indexOf(': ') + 2)]) ) buffer = buffer.slice(end + 2) if (fields.data) frames.push({ id: Number(fields.id), event: fields.event, data: JSON.parse(fields.data) }) } } } catch { // aborted } })() const until = async (check: () => boolean, ms = 3000) => { for (const start = Date.now(); !check(); await new Promise((r) => setTimeout(r, 20))) { if (Date.now() - start > ms) throw new Error(`no such frame after ${ms} ms: ${JSON.stringify(frames.map((f) => f.event))}`) } } return { res, frames, until, close: () => abort.abort() } } async function canvasMcp(board: string, member = 'm_ana') { const client = new Client({ name: 'test', version: '0' }) const headers = { Authorization: `Bearer ${member}`, 'X-Run-Id': 'run_00112233aabb' } await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/api/mcp/canvas/${board}`), { requestInit: { headers } })) const tool = async (name: string, args: Record = {}) => JSON.parse(((await client.callTool({ name, arguments: args })) as { content: { text: string }[] }).content[0].text) return { client, tool } } test('boards: create one, list the caller’s, read and edit it', async () => { const anonymous = await call('POST', '/boards', { member: null, body: {} }) assert.equal(anonymous.status, 401) assert.deepEqual(anonymous.body, { error: { code: 'unauthorized', message: 'Send Authorization: Bearer ' } }) assert.equal(anonymous.headers.get('www-authenticate'), 'Bearer') const created = await call('POST', '/boards', { body: { title: 'Melanoma', question: 'What predicts response to checkpoint therapy?' } }) assert.equal(created.status, 201) const board = created.body assert.match(board.id, /^b_[a-z0-9]{10}$/) assert.equal(board.createdBy, 'm_ana') assert.deepEqual(board.goals.map((g: { id: string }) => g.id), ['new-findings', 'methods']) assert.deepEqual(board.members.map((m: { memberId: string }) => m.memberId), ['m_ana']) assert.equal(board.agentActive, false) assert.match(board.createdAt, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/) assert.ok((await call('GET', '/boards')).body.some((b: { id: string }) => b.id === board.id)) assert.ok(!(await call('GET', '/boards', { member: 'm_eve' })).body.some((b: { id: string }) => b.id === board.id)) const goals = [{ id: 'evidence', label: 'Evidence' }] const edited = await call('PATCH', `/boards/${board.id}`, { body: { question: 'Which T cells respond?', goals } }) assert.equal(edited.status, 200) assert.equal(edited.body.title, 'Melanoma') const read = await call('GET', `/boards/${board.id}`, { member: null }) assert.equal(read.body.question, 'Which T cells respond?') assert.deepEqual(read.body.goals, goals) assert.equal(read.body.unvoted, undefined, 'caller-only counts need a bearer') }) test('opening an old room makes its board row and leaves the canvas as it was', async () => { const board = roomWith('seed:1', 'pmid:2') const before = readRoom(board, (txn) => [...txn.values()].length) const res = await call('GET', `/boards/${board}`) assert.equal(res.status, 200) assert.equal(res.body.id, board) assert.equal(res.body.seedCount, 1) assert.equal(res.body.unvoted, 1) assert.equal(readRoom(board, (txn) => [...txn.values()].length), before) }) test('bad requests get a JSON error with a code and the reason', async () => { const board = roomWith('pmid:1') const cases: [string, string, Options, number, string, RegExp][] = [ ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: { decision: 'maybe' } }, 400, 'invalid', /decision/], ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: 'not json' }, 400, 'bad_json', /JSON/], ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: { decision: 'keep', by: 'm_x' } }, 400, 'invalid', /by/], ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { member: 'ana', body: { decision: 'keep' } }, 401, 'unauthorized', /Bearer/], ['PUT', `/boards/${board}/papers/pmid%3A404/vote`, { body: { decision: 'keep' } }, 404, 'not_found', /No card for pmid:404/], ['GET', '/boards/bad%20board', {}, 400, 'bad_request', /board ID/], ['PATCH', `/boards/${board}`, { body: {} }, 400, 'invalid', /at least one/], ['PATCH', `/boards/${board}`, { body: { goals: [{ id: 'Bad Goal', label: 'x' }] } }, 400, 'invalid', /goals\.0\.id/], ['PUT', `/boards/${board}/removed-links`, { body: { from: 'pmid:1', to: 'pmid:2' } }, 400, 'invalid', /kind/], ['POST', `/boards/${board}/seeds`, { body: { pmid: 'abc' } }, 400, 'invalid', /PMID/], ['POST', `/boards/${board}/seen`, { body: { seq: -1 } }, 400, 'invalid', /seq/], ['GET', `/boards/${board}/papers?status=maybe`, {}, 400, 'invalid', /status/], ['GET', `/boards/${board}/activity?limit=0`, {}, 400, 'invalid', /limit/], ['GET', `/boards/${board}/events?after=x`, {}, 400, 'invalid', /seq/], ['GET', '/nope', {}, 404, 'not_found', /No route/], ] for (const [method, path, options, status, code, message] of cases) { const res = await call(method, path, options) assert.equal(res.status, status, `${method} ${path}: ${JSON.stringify(res.body)}`) assert.equal(res.body.error.code, code, `${method} ${path}`) assert.match(res.body.error.message, message, `${method} ${path}`) } const wrongMethod = await call('DELETE', `/boards/${board}`) assert.equal(wrongMethod.status, 405) assert.equal(wrongMethod.headers.get('allow'), 'GET, PATCH') assert.deepEqual(cardOf(board, 'pmid:1').props.keepBy, [], 'nothing was written') }) test('Keep and Discard change the card for everyone; the paper record shows the votes and the papers MCP’s fields', async () => { const board = roomWith(TIROSH.paperId, 'pmid:9') assert.deepEqual((await vote(board, TIROSH.paperId, 'm_ana', 'keep')).body, { status: 'kept', keepBy: ['m_ana'], discardBy: [] }) assert.equal(cardOf(board, TIROSH.paperId).props.status, 'kept') assert.deepEqual((await vote(board, TIROSH.paperId, 'm_ben', 'discard')).body, { status: 'contested', keepBy: ['m_ana'], discardBy: ['m_ben'], }) const card = cardOf(board, TIROSH.paperId).props assert.deepEqual([card.status, card.keepBy, card.discardBy], ['contested', ['m_ana'], ['m_ben']]) const paper = (await call('GET', `/boards/${board}/papers/${encodeURIComponent(TIROSH.paperId)}`, { member: null })).body for (const key of ['title', 'authors', 'year', 'venue', 'abstract', 'openAccessPdf', 'ids'] as const) { assert.deepEqual(paper[key], TIROSH[key], key) } assert.equal(paper.pdf, `/api/v1/boards/${board}/papers/pmid%3A27124452/pdf`) assert.deepEqual(paper.board, { kind: 'suggestion', status: 'contested', votes: { keep: ['m_ana'], discard: ['m_ben'] }, working: false, addedBy: 'agent', onBehalfOf: 'm_ana', runId: null, addedAt: paper.board.addedAt, }) assert.equal(paper.skipped, undefined) const list = (await call('GET', `/boards/${board}/papers`)).body assert.deepEqual(list.map((p: { paperId: string }) => p.paperId).sort(), [TIROSH.paperId, 'pmid:9']) const contested = (await call('GET', `/boards/${board}/papers?status=contested,kept`)).body assert.deepEqual(contested.map((p: { paperId: string; keepBy: string[] }) => [p.paperId, p.keepBy]), [[TIROSH.paperId, ['m_ana']]]) // the papers MCP doesn't know pmid:9: the board's part still comes back, and says what's missing const unknown = (await call('GET', `/boards/${board}/papers/pmid%3A9`)).body assert.equal(unknown.title, 'Paper pmid:9') assert.match(unknown.skipped[0], /no record for pmid:9/) }) test('SSE: votes and agent writes arrive as they happen, and a reconnect gets what it missed, once', async () => { const board = roomWith('pmid:1') const stream = await listen(`/boards/${board}/events`) assert.equal(stream.res.headers.get('content-type'), 'text/event-stream; charset=utf-8') assert.equal(stream.res.headers.get('x-accel-buffering'), 'no') const agent = await canvasMcp(board) assert.deepEqual((await agent.tool('add_papers', { papers: [{ paperId: 'pmid:2', title: 'Two', near: 'pmid:1' }] })).added, ['pmid:2']) await stream.until(() => stream.frames.some((f) => f.event === 'paper.added')) const added = stream.frames.find((f) => f.event === 'paper.added')! assert.deepEqual( { ...added.data, at: '' }, { seq: added.id, boardId: board, actor: 'agent', onBehalfOf: 'm_ana', runId: 'run_00112233aabb', type: 'paper.added', payload: { tool: 'add_papers', paperIds: ['pmid:2'] }, at: '', } ) await agent.tool('add_note', { text: 'Worth a look', paperId: 'pmid:2' }) await vote(board, 'pmid:2', 'm_ana', 'keep') await stream.until(() => stream.frames.some((f) => f.event === 'paper.voted')) assert.deepEqual(stream.frames.map((f) => f.event), ['paper.added', 'note.added', 'member.joined', 'paper.voted']) assert.equal(stream.frames.at(-1)!.data.payload.status, 'kept') stream.close() await agent.client.close() // Ben's vote happens while the stream is away const lastId = stream.frames.at(-1)!.id await vote(board, 'pmid:2', 'm_ben', 'discard') const back = await listen(`/boards/${board}/events`, { 'Last-Event-ID': String(lastId) }) await back.until(() => back.frames.some((f) => f.event === 'paper.voted')) await vote(board, 'pmid:1', 'm_ben', 'keep') await back.until(() => back.frames.filter((f) => f.event === 'paper.voted').length === 2) assert.deepEqual(back.frames.map((f) => [f.event, f.data.actor]), [ ['member.joined', 'm_ben'], ['paper.voted', 'm_ben'], ['paper.voted', 'm_ben'], ]) assert.ok(back.frames.every((f, i) => f.id > lastId && (i === 0 || f.id > back.frames[i - 1].id)), 'each once, in order') assert.equal(streamCount(board), 1, 'the first stream was cleaned up when it closed') back.close() await back.until(() => streamCount(board) === 0) const fromStart = await listen(`/boards/${board}/events?after=0`) await fromStart.until(() => fromStart.frames.length === 7) fromStart.close() const activity = (await call('GET', `/boards/${board}/activity`)).body assert.deepEqual(activity.map((e: { seq: number }) => e.seq), [...fromStart.frames].reverse().map((f) => f.id), 'newest first') assert.equal((await call('GET', `/boards/${board}/activity?after=${activity[1].seq}`)).body.length, 1) }) test('what people discard and the links they remove reach the agent through get_board', async () => { const board = roomWith('pmid:a', 'pmid:b') await vote(board, 'pmid:a', 'm_ben', 'discard') const removed = await call('PUT', `/boards/${board}/removed-links`, { body: { from: 'pmid:a', to: 'pmid:b', kind: 'cites' } }) assert.equal(removed.status, 204) const agent = await canvasMcp(board) const view = await agent.tool('get_board') assert.deepEqual(view.discardedIds, ['pmid:a']) assert.deepEqual(view.removedLinks, [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites' }]) const linked = await agent.tool('link_papers', { links: [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites' }] }) assert.deepEqual(linked.skipped, [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites', reason: 'removed by a person' }]) await agent.client.close() const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) assert.ok(types.includes('link.removed')) }) test('members join and rename; "seen" clears the Catch me up count', async () => { const board = roomWith('pmid:1', 'pmid:2') const joined = await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana' } }) assert.equal(joined.status, 200) assert.equal(joined.body.displayName, 'Ana') const renamed = await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana K.' } }) assert.deepEqual(renamed.body, { ...joined.body, displayName: 'Ana K.' }) await vote(board, 'pmid:1', 'm_ben', 'keep') const before = (await call('GET', `/boards/${board}`)).body assert.equal(before.newSinceLastSeen, 2, "Ben joining and voting; Ana's own join doesn't count") assert.equal(before.unvoted, 2) assert.equal((await call('POST', `/boards/${board}/seen`, { body: { seq: before.lastSeq } })).status, 204) const after = (await call('GET', `/boards/${board}`)).body assert.equal(after.newSinceLastSeen, 0) assert.deepEqual(after.members.map((m: { memberId: string; displayName: string }) => [m.memberId, m.displayName]), [ ['m_ana', 'Ana K.'], ['m_ben', ''], ]) }) test('a seed by PMID becomes a seed card authored by the member; an unknown one is a 404', async () => { const board = roomWith('pmid:1') const res = await call('POST', `/boards/${board}/seeds`, { body: { pmid: '27124452' } }) assert.equal(res.status, 201) assert.equal(res.body.paperId, TIROSH.paperId) assert.equal(res.body.abstract, TIROSH.abstract) assert.deepEqual([res.body.board.kind, res.body.board.status, res.body.board.addedBy], ['seed', 'kept', 'm_ana']) const card = cardOf(board, TIROSH.paperId) assert.deepEqual(card.meta, { author: 'm_ana', paperId: TIROSH.paperId }) assert.deepEqual([card.props.title, card.props.year, card.props.type], [TIROSH.title, 2016, 'article']) assert.equal((await call('POST', `/boards/${board}/seeds`, { body: { pmid: '27124452' } })).status, 201, 'again: same card') assert.equal(readRoom(board, (txn) => [...txn.values()].filter((r) => r.typeName === 'shape').length), 2) const unknown = await call('POST', `/boards/${board}/seeds`, { body: { pmid: '404' } }) assert.equal(unknown.status, 404) assert.equal(unknown.body.error.code, 'paper_not_found') const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) assert.deepEqual(types.filter((t: string) => t === 'seed.added').length, 2) }) /** Posts a file as the Start page does: multipart, field "file". */ async function postFile(board: string, name: string, data: Uint8Array, type = 'application/pdf') { const form = new FormData() form.append('file', new Blob([data], { type }), name) const res = await fetch(`${base}/api/v1/boards/${board}/seeds`, { method: 'POST', headers: { authorization: 'Bearer m_ana' }, body: form }) return { status: res.status, body: await res.json() } } test('a PDF seed is found by its DOI, else by its title; without either it is a 422 that says what to do', async () => { const board = roomWith('pmid:1') const withDoi = textPdf([ { text: 'Research article', size: 9 }, { text: 'Dissecting the multicellular ecosystem', size: 18 }, { text: 'Science 352, 189 (2016). DOI: 10.1126/science.aad0501.', size: 8 }, ]) const byDoi = await postFile(board, 'tirosh.pdf', withDoi) assert.equal(byDoi.status, 201, JSON.stringify(byDoi.body)) assert.deepEqual([byDoi.body.paperId, byDoi.body.board.kind, byDoi.body.board.addedBy], [TIROSH.paperId, 'seed', 'm_ana']) assert.equal(cardOf(board, TIROSH.paperId).props.title, TIROSH.title) // kept with the uploads, where /api/uploads/ serves it const upload = byDoi.body.upload.replace('/api/uploads/', '') assert.deepEqual(new Uint8Array(readFileSync(join(dir, 'uploads', upload))), withDoi) assert.equal(readFileSync(join(dir, 'uploads', `${upload}.type`), 'utf8'), 'application/pdf') const manuscript = textPdf([ { text: 'Author manuscript', size: 9 }, { text: 'Defining T cell states associated with response', size: 18 }, { text: 'to checkpoint immunotherapy in melanoma', size: 18 }, { text: 'Moshe Sade-Feldman, Keren Yizhak', size: 11 }, ]) const byTitle = await postFile(board, 'sade-feldman.pdf', manuscript) assert.equal(byTitle.status, 201, JSON.stringify(byTitle.body)) assert.equal(byTitle.body.paperId, SADE.paperId) const notes = await postFile(board, 'notes.pdf', textPdf([{ text: 'Lab meeting notes', size: 12 }])) assert.deepEqual([notes.status, notes.body], [422, { error: { code: 'no_doi', message: "Couldn't find a DOI in notes.pdf. Paste its DOI instead." } }]) const garbled = await postFile(board, 'scan.pdf', new TextEncoder().encode('not a pdf')) assert.deepEqual([garbled.status, garbled.body.error.code], [422, 'bad_pdf']) const text = await postFile(board, 'refs.txt', new TextEncoder().encode('10.1126/science.aad0501'), 'text/plain') assert.deepEqual([text.status, text.body.error.code, text.body.error.message], [415, 'unsupported_type', "refs.txt isn't a PDF"]) const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) assert.equal(types.filter((t: string) => t === 'seed.added').length, 2, 'only the two found papers became seeds') assert.equal(readRoom(board, (txn) => [...txn.values()].filter((r) => r.typeName === 'shape').length), 3) }) test('Home’s counts: papers on the board and, for the caller, what’s new since they last looked', async () => { const board = roomWith('seed:1', 'pmid:2') await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana' } }) const agent = await canvasMcp(board, 'm_ben') await agent.tool('add_papers', { papers: [{ paperId: 'pmid:3', title: 'Three', near: 'seed:1' }] }) await agent.client.close() const read = (await call('GET', `/boards/${board}`)).body assert.deepEqual([read.seedCount, read.paperCount, read.newCount], [1, 3, 1]) assert.equal(read.newCount, read.newSinceLastSeen) const listed = (await call('GET', '/boards')).body.find((b: { id: string }) => b.id === board) assert.deepEqual([listed.seedCount, listed.paperCount, listed.newCount], [1, 3, 1]) assert.equal((await call('GET', `/boards/${board}`, { member: null })).body.newCount, undefined, 'newCount needs a bearer') await call('POST', `/boards/${board}/seen`, { body: { seq: read.lastSeq } }) assert.equal((await call('GET', '/boards')).body.find((b: { id: string }) => b.id === board).newCount, 0) }) test('the PDF route refuses a link that isn’t https on a public host, and 404s without one', async () => { const board = roomWith(TIROSH.paperId, 'pmid:2') const refused = await call('GET', `/boards/${board}/papers/${encodeURIComponent(TIROSH.paperId)}/pdf`) assert.equal(refused.status, 502) assert.equal(refused.body.error.code, 'pdf_refused') assert.match(refused.body.error.message, /Only https/) const none = await call('GET', `/boards/${board}/papers/pmid%3A2/pdf`) assert.deepEqual([none.status, none.body.error.code], [404, 'no_pdf']) assert.equal((await call('GET', `/boards/${board}/papers/pmid%3A3/pdf`)).status, 404) })