From 10ff2949163f0d7ffbd88674729e75f89950b765 Mon Sep 17 00:00:00 2001 From: Karthik Palaniappan Date: Mon, 5 Oct 2026 01:51:52 -0400 Subject: [PATCH] feat: return SEP-3094 citations from all four tools Clients that declare citation support get a top-level citations array while answer text stays unchanged. Clients that do not keep the existing footer and search list. Co-authored-by: Cursor --- README.md | 51 ++++++ src/citations.test.ts | 377 ++++++++++++++++++++++++++++++++++++++++++ src/citations.ts | 287 ++++++++++++++++++++++++++++++++ src/index.test.ts | 22 +-- src/server.test.ts | 178 ++++++++++++++++++++ src/server.ts | 128 +++++++++++--- 6 files changed, 1007 insertions(+), 36 deletions(-) create mode 100644 src/citations.test.ts create mode 100644 src/citations.ts diff --git a/README.md b/README.md index 6d167b1..e4bdbde 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,57 @@ Advanced reasoning and problem-solving backed by the Agent API `medium` preset. > [!NOTE] > Presets are managed configurations (model, search setup, step budget) that Perplexity keeps tuned over time; see the [presets guide](https://docs.perplexity.ai/docs/agent-api/presets). Earlier versions of this server called the legacy `sonar-pro`, `sonar-reasoning-pro`, and `sonar-deep-research` models and accepted `strip_thinking` / `reasoning_effort` parameters. Those parameters are no longer part of the tool schemas and are ignored if sent; the Agent API produces no `` tags. +## Granular Citations + +By default, tool results are plain text and include no `citations` array. `perplexity_ask`, `perplexity_research`, and `perplexity_reason` leave the answer body unchanged, including inline markers such as `[1]`, and append a footer: + +```text +Citations: +[1] https://example.com/source +``` + +Each footer line uses the search result's numeric id. When those ids are missing, or the same id maps to more than one URL, the footer lists unique URLs with positional numbers instead. `perplexity_search` returns a numbered list of titles, URLs, snippets, and dates, with no footer. + +Clients that want those sources as structured metadata, rather than only in that text, can opt in to the draft [SEP-3094 granular citations format](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3094). All four tools support it. A client opts in on each `tools/call` request through: + +```json +{ + "_meta": { + "io.modelcontextprotocol/clientCapabilities": { + "citations": { + "render": {} + } + } + } +} +``` + +When `citations` is declared, the tool result includes a top-level `citations` array with the page title, site, URL, excerpt, and publication date when available. `name` is the page title. `publisher.name` is the hostname with a leading `www.` and the public suffix removed, such as `apnews.com` becoming `apnews`. Agent tools bind stable numeric source markers such as `[2]` to citation `id: "2"`. Search results use block-level citations. + +When `citations.render` is declared, agent tools omit the legacy `Citations:` footer because the client can render citation chips or links. Clients that do not opt in continue receiving the existing text format. + +A `perplexity_ask` result for a client that declared `citations.render` looks like this. `content` is the answer without the footer, and `citations` carries the source for `[1]`: + +```json +{ + "content": [ + { + "type": "text", + "text": "Supported claim[1]." + } + ], + "citations": [ + { + "id": "1", + "name": "Page Title", + "publisher": { "name": "example" }, + "url": "https://example.com/source#:~:text=Supporting%20passage", + "text": "Supporting passage" + } + ] +} +``` + ## Use as a Library The package also exports the server factory for embedding in your own Node process: diff --git a/src/citations.test.ts b/src/citations.test.ts new file mode 100644 index 0000000..a407046 --- /dev/null +++ b/src/citations.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { + citationsFromAgentResponse, + citationsFromSearchResponse, + getCitationCapabilities, + withTextFragment, +} from "./citations.js"; +import { formatAgentResponseText } from "./server.js"; +import type { AgentResponse, SearchResponse } from "./types.js"; + +describe("SEP-3094 citations", () => { + it("detects citation and rendering capabilities from request metadata", () => { + expect(getCitationCapabilities(undefined)).toEqual({ + supported: false, + render: false, + }); + expect( + getCitationCapabilities({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + citations: {}, + }, + }, + }), + ).toEqual({ supported: true, render: false }); + expect( + getCitationCapabilities({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + citations: { render: {} }, + }, + }, + }), + ).toEqual({ supported: true, render: true }); + }); + + it("maps stable Agent API result ids to citation ids", () => { + const response: AgentResponse = { + output: [ + { + type: "search_results", + results: [ + { + id: 2, + url: "https://example.com/article", + title: "Article", + snippet: "A directly quoted supporting passage.", + date: "2026-09-23", + }, + ], + }, + { + type: "message", + content: [ + { + type: "output_text", + text: "Supported claim[2].", + annotations: [ + { + type: "url_citation", + url: "https://example.com/article", + title: "Annotation title", + }, + ], + }, + ], + }, + ], + }; + + expect(citationsFromAgentResponse(response)).toEqual([ + { + id: "2", + name: "Article", + publisher: { name: "example" }, + url: + "https://example.com/article#:~:text=A%20directly%20quoted%20supporting%20passage.", + text: "A directly quoted supporting passage.", + datePublished: "2026-09-23", + }, + ]); + }); + + it("leaves an ordinary inline link out of the citations array", () => { + const text = + "The holding is quoted in [the opinion](https://www.law.cornell.edu/supremecourt/text/5/137)[1]."; + const response: AgentResponse = { + output: [ + { + type: "search_results", + results: [ + { + id: 1, + url: "https://example.com/case-summary", + title: "Case summary", + snippet: "The judicial department says what the law is.", + }, + ], + }, + { + type: "message", + content: [ + { + type: "output_text", + text, + annotations: [ + { + type: "url_citation", + url: "https://example.com/case-summary", + title: "Case summary", + }, + ], + }, + ], + }, + ], + }; + + const citations = citationsFromAgentResponse(response); + + expect(citations).toEqual([ + { + id: "1", + name: "Case summary", + publisher: { name: "example" }, + url: + "https://example.com/case-summary#:~:text=The%20judicial%20department%20says%20what%20the%20law%20is.", + text: "The judicial department says what the law is.", + }, + ]); + expect(JSON.stringify(citations)).not.toContain( + "https://www.law.cornell.edu/supremecourt/text/5/137", + ); + expect(formatAgentResponseText(response).startsWith(text)).toBe(true); + }); + + it("uses block-level citations when Agent API ids are ambiguous", () => { + const response: AgentResponse = { + output: [ + { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/one" }, + { id: 1, url: "https://example.com/two" }, + { id: 1, url: "https://example.com/one" }, + ], + }, + ], + }; + + expect(citationsFromAgentResponse(response)).toEqual([ + { publisher: { name: "example" }, url: "https://example.com/one" }, + { publisher: { name: "example" }, url: "https://example.com/two" }, + ]); + }); + + it("falls back to output annotations when search results are absent", () => { + const response: AgentResponse = { + output: [ + { + type: "message", + content: [ + { + type: "output_text", + text: "Claim", + annotations: [ + { + type: "url_citation", + url: "https://example.com/source", + title: "Source", + }, + ], + }, + ], + }, + ], + }; + + expect(citationsFromAgentResponse(response)).toEqual([ + { + name: "Source", + publisher: { name: "example" }, + url: "https://example.com/source", + }, + ]); + }); + + it("maps search results to unique block-level citations", () => { + const response: SearchResponse = { + results: [ + { + title: "First", + url: "https://example.com/first", + snippet: "First result excerpt", + }, + { + title: "Duplicate", + url: "https://example.com/first", + }, + ], + }; + + expect(citationsFromSearchResponse(response)).toEqual([ + { + name: "First", + publisher: { name: "example" }, + url: "https://example.com/first#:~:text=First%20result%20excerpt", + text: "First result excerpt", + }, + ]); + }); + + it("uses the site label as the citation name", () => { + const response: SearchResponse = { + results: [ + { title: "AP story", url: "https://www.apnews.com/article", snippet: "Wire" }, + { title: "Wiki", url: "https://en.wikipedia.org/wiki/Marbury", snippet: "Case" }, + { title: "BBC", url: "https://www.bbc.co.uk/news/story", snippet: "Report" }, + ], + }; + + expect(citationsFromSearchResponse(response).map((citation) => citation.name)).toEqual([ + "AP story", + "Wiki", + "BBC", + ]); + expect(citationsFromSearchResponse(response).map((citation) => citation.publisher)).toEqual([ + { name: "apnews" }, + { name: "wikipedia" }, + { name: "bbc" }, + ]); + }); + + it("stores citation text without markdown markers", () => { + const response: SearchResponse = { + results: [ + { + title: "Story", + url: "https://apnews.com/story", + snippet: "**Court** says [the holding](https://example.com/opinion) is *settled*.", + }, + ], + }; + + expect(citationsFromSearchResponse(response)).toEqual([ + { + name: "Story", + publisher: { name: "apnews" }, + url: "https://apnews.com/story#:~:text=Court%20says%20the%20holding%20is%20settled.", + text: "Court says the holding is settled.", + }, + ]); + }); + + it("removes heading markers that the snippet runs together", () => { + const response: SearchResponse = { + results: [ + { + title: "Sea turtle eggs make history on California coast", + url: "https://apnews.com/article/sea-turtle-eggs", + snippet: + "Agency says sea turtle eggs are a first for the US West Coast ## Agency says sea turtle eggs are a first.", + }, + ], + }; + + expect(citationsFromSearchResponse(response)[0].text).toBe( + "Agency says sea turtle eggs are a first for the US West Coast Agency says sea turtle eggs are a first.", + ); + expect(withTextFragment("https://example.com/page", "Learn C# today")).toBe( + "https://example.com/page#:~:text=Learn%20C%23%20today", + ); + }); + + it("puts only plain text in text fragments", () => { + expect( + withTextFragment( + "https://apnews.com/story", + "**Court** says [the holding](https://example.com/opinion) is *settled*.", + ), + ).toBe( + "https://apnews.com/story#:~:text=Court%20says%20the%20holding%20is%20settled.", + ); + expect( + withTextFragment( + "https://example.com/page", + "# Heading\nUse `snake_case` here.\n> quoted", + ), + ).toBe( + "https://example.com/page#:~:text=Heading%20Use%20snake_case%20here.%20quoted", + ); + expect(withTextFragment("https://example.com/page", "**")).toBe("https://example.com/page"); + }); + + it("uses start and end anchors for long quotes", () => { + const quote = `A ${"complete supporting passage ".repeat(10).trim()}.`; + expect(withTextFragment("https://example.com/page", quote)).toBe( + "https://example.com/page#:~:text=A%20complete%20supporting%20passage%20complete," + + "supporting%20passage%20complete%20supporting%20passage.", + ); + }); + + it("encodes dashes so they are not read as directive delimiters", () => { + expect(withTextFragment("https://example.com/page", "a first-ever nest")).toBe( + "https://example.com/page#:~:text=a%20first%2Dever%20nest", + ); + }); + + it("picks one contiguous sentence from a multi-part snippet", () => { + const snippet = [ + "# Agency says sea turtle eggs laid on California beaches are a first for the US West Coast", + "", + "...", + "## Agency says sea turtle eggs laid on California beaches are a first for the US West Coast", + "", + "Sea turtle eggs have been discovered at two sites in coastal California which authorities say are the “first-ever recorded” nests on the U.S. West Coast for any sea turtle species. ... HUNTINGTON BEACH, Calif. (AP) — A reptile believed to be struggling in sand at a Southern California beach was simply making history.", + ].join("\n"); + const response: SearchResponse = { + results: [ + { + title: "Sea turtle eggs make history on California coast", + url: "https://apnews.com/article/california-beaches-turtle-nests", + snippet, + }, + ], + }; + + const [citation] = citationsFromSearchResponse(response); + + expect(citation.text).toBe( + "Sea turtle eggs have been discovered at two sites in coastal California which authorities say are the “first-ever recorded” nests on the U.S. West Coast for any sea turtle species.", + ); + expect(citation.url).toBe( + "https://apnews.com/article/california-beaches-turtle-nests#:~:text=" + + "Sea%20turtle%20eggs%20have%20been,for%20any%20sea%20turtle%20species.", + ); + }); + + it("skips page titles and standalone datelines", () => { + const snippet = [ + "Agency says sea turtle eggs laid on California beaches are a first for the US West Coast - The Washington Post", + "HUNTINGTON BEACH, Calif.", + "An olive ridley turtle — maybe the same one — laid eggs on Sept. 19, a few miles away near the Huntington Beach Pier in Orange County, authorities said.", + ].join("\n"); + + expect( + citationsFromSearchResponse({ + results: [{ title: "Story", url: "https://www.washingtonpost.com/story", snippet }], + })[0].text, + ).toBe( + "An olive ridley turtle — maybe the same one — laid eggs on Sept. 19, a few miles away near the Huntington Beach Pier in Orange County, authorities said.", + ); + }); + + it("falls back to the first block when none reads like body text", () => { + expect( + citationsFromSearchResponse({ + results: [ + { title: "Story", url: "https://example.com/story", snippet: "Short title - Site\nAnother label" }, + ], + })[0].text, + ).toBe("Short title - Site"); + }); + + it("only adds text fragments to fragment-free web URLs", () => { + expect(withTextFragment("https://example.com/page", "quoted text")).toBe( + "https://example.com/page#:~:text=quoted%20text", + ); + expect( + withTextFragment("https://example.com/page#section", "quoted text"), + ).toBe("https://example.com/page#section"); + expect(withTextFragment("file:///tmp/source", "quoted text")).toBe( + "file:///tmp/source", + ); + expect(withTextFragment("not a URL", "quoted text")).toBe("not a URL"); + }); +}); diff --git a/src/citations.ts b/src/citations.ts new file mode 100644 index 0000000..3a76908 --- /dev/null +++ b/src/citations.ts @@ -0,0 +1,287 @@ +import type { + AgentAnnotation, + AgentResponse, + AgentSearchResult, + SearchResponse, + SearchResult, +} from "./types.js"; + +export interface CitationPublisher { + name: string; +} + +export interface Citation { + id?: string; + name?: string; + url?: string; + text?: string; + datePublished?: string; + publisher?: CitationPublisher; +} + +export interface CitationCapabilities { + supported: boolean; + render: boolean; +} + +interface ToolExtra { + _meta?: Record; +} + +interface CitationSupport { + render?: Record; +} + +export function getCitationCapabilities(extra: unknown): CitationCapabilities { + if (!extra || typeof extra !== "object") { + return { supported: false, render: false }; + } + + const meta = (extra as ToolExtra)._meta; + const clientCapabilities = meta?.["io.modelcontextprotocol/clientCapabilities"]; + if (!clientCapabilities || typeof clientCapabilities !== "object") { + return { supported: false, render: false }; + } + + const citations = (clientCapabilities as Record).citations; + if (!citations || typeof citations !== "object") { + return { supported: false, render: false }; + } + + return { + supported: true, + render: typeof (citations as CitationSupport).render === "object", + }; +} + +export function citationsFromAgentResponse(response: AgentResponse): Citation[] { + const results: AgentSearchResult[] = []; + const annotationByUrl = new Map(); + + for (const item of response.output) { + if (item.type === "search_results" && Array.isArray(item.results)) { + results.push(...item.results); + } + + if (item.type !== "message" || !Array.isArray(item.content)) continue; + for (const part of item.content) { + for (const annotation of part.annotations ?? []) { + if (annotation.url && !annotationByUrl.has(annotation.url)) { + annotationByUrl.set(annotation.url, annotation); + } + } + } + } + + const usableResults = results.filter( + (result): result is AgentSearchResult & { url: string } => + typeof result.url === "string" && result.url.length > 0, + ); + if (usableResults.length === 0) { + return citationsFromAnnotations(annotationByUrl.values()); + } + + const urlById = new Map(); + let idsUsable = true; + for (const result of usableResults) { + if (typeof result.id !== "number") { + idsUsable = false; + continue; + } + const existing = urlById.get(result.id); + if (existing === undefined) { + urlById.set(result.id, result.url); + } else if (existing !== result.url) { + idsUsable = false; + } + } + + const seen = new Set(); + const citations: Citation[] = []; + for (const result of usableResults) { + const dedupeKey = idsUsable ? `${result.id}\0${result.url}` : result.url; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const annotation = annotationByUrl.get(result.url); + const passage = citationPassage(result.snippet ?? undefined); + citations.push( + compactCitation({ + ...(idsUsable && typeof result.id === "number" + ? { id: String(result.id) } + : {}), + ...citationSource(result.url, result.title ?? annotation?.title ?? undefined), + url: withTextFragment(result.url, passage), + text: passage, + datePublished: result.date ?? undefined, + }), + ); + } + + return citations; +} + +export function citationsFromSearchResponse(response: SearchResponse): Citation[] { + return dedupeSearchResults(response.results).map((result) => { + const passage = citationPassage(result.snippet); + return compactCitation({ + ...citationSource(result.url, result.title), + url: withTextFragment(result.url, passage), + text: passage, + datePublished: result.date, + }); + }); +} + +const FRAGMENT_ANCHOR_WORDS = 5; +const MAX_PASSAGE_CHARS = 300; + +export function withTextFragment(url: string, text?: string): string { + const quote = plainText(text ?? ""); + if (!quote) return url; + + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.hash) { + return url; + } + parsed.hash = `:~:text=${textDirective(quote)}`; + return parsed.toString(); + } catch { + return url; + } +} + +// Long quotes use the textStart,textEnd form so the URL stays short while still +// highlighting the whole passage. +function textDirective(quote: string): string { + const words = quote.split(" "); + if (words.length <= FRAGMENT_ANCHOR_WORDS * 2) { + return encodeDirectiveText(quote); + } + const start = words.slice(0, FRAGMENT_ANCHOR_WORDS).join(" "); + const end = words.slice(-FRAGMENT_ANCHOR_WORDS).join(" "); + return `${encodeDirectiveText(start)},${encodeDirectiveText(end)}`; +} + +// "-" and "," delimit prefix, suffix, start, and end in a text directive. +function encodeDirectiveText(text: string): string { + return encodeURIComponent(text).replace(/-/g, "%2D"); +} + +function citationsFromAnnotations(annotations: Iterable): Citation[] { + const citations: Citation[] = []; + for (const annotation of annotations) { + if (!annotation.url && !annotation.title) continue; + citations.push( + compactCitation({ + ...citationSource(annotation.url ?? undefined, annotation.title ?? undefined), + url: annotation.url ?? undefined, + }), + ); + } + return citations; +} + +function dedupeSearchResults(results: SearchResult[]): SearchResult[] { + const seen = new Set(); + return results.filter((result) => { + if (seen.has(result.url)) return false; + seen.add(result.url); + return true; + }); +} + +const MULTI_PART_SUFFIXES = new Set([ + "ac.uk", + "co.in", + "co.jp", + "co.kr", + "co.nz", + "co.uk", + "com.au", + "com.br", + "com.hk", + "com.mx", + "com.sg", + "com.tw", + "gov.uk", + "net.au", + "org.au", + "org.uk", +]); + +function citationSource(url?: string, title?: string): Pick { + const site = url ? truncatedHostname(url) : undefined; + return { + ...(title ? { name: title } : {}), + ...(site ? { publisher: { name: site } } : {}), + }; +} + +// Snippets join non-contiguous extracts with "..." and line breaks, so a fragment +// built from the whole snippet never matches the page. Use the first block that +// reads like body text: not a heading, at least six words, ending a sentence. +// Titles and datelines fail those checks. +function citationPassage(snippet?: string): string | undefined { + if (!snippet) return undefined; + + const blocks = snippet + .split(/\n+|\s*(?:\.{3}|…)\s*/) + .filter((block) => !/^\s*#{1,6}\s/.test(block)) + .map(plainText) + .filter(Boolean); + const passage = + blocks.find( + (block) => block.split(" ").length >= 6 && /[.!?]["'”’)]*$/.test(block), + ) ?? blocks[0]; + return passage ? truncateAtWord(passage, MAX_PASSAGE_CHARS) : undefined; +} + +function truncateAtWord(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const cut = text.slice(0, maxChars + 1); + const lastSpace = cut.lastIndexOf(" "); + return (lastSpace > 0 ? cut.slice(0, lastSpace) : text.slice(0, maxChars)).trim(); +} + +function plainText(text: string): string { + const withoutLinks = text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/`([^`]*)`/g, "$1"); + const withoutMarkers = withoutLinks + .replace(/\*\*|__|~~/g, "") + .replace(/(^|[\s(\[])[*_]+(?=\S)/g, "$1") + .replace(/(\S)[*_]+(?=[\s.,;:!?)\]'"]|$)/g, "$1") + .replace(/(^|\s)#{1,6}(?=\s)/g, "$1") + .replace(/^\s{0,3}>\s?/gm, "") + .replace(/^\s{0,3}(?:[-+]|\d+\.)\s+/gm, ""); + return withoutMarkers.replace(/\s+/g, " ").trim(); +} + +function truncatedHostname(url: string): string | undefined { + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } + if (hostname.startsWith("www.")) hostname = hostname.slice("www.".length); + const labels = hostname.split(".").filter(Boolean); + if (labels.length === 0) return undefined; + if (labels.length === 1 || labels.every((label) => /^\d+$/.test(label))) { + return hostname; + } + const suffix = labels.slice(-2).join("."); + if (labels.length >= 3 && MULTI_PART_SUFFIXES.has(suffix)) { + return labels[labels.length - 3]; + } + return labels[labels.length - 2]; +} + +function compactCitation(citation: Citation): Citation { + return Object.fromEntries( + Object.entries(citation).filter(([, value]) => value !== undefined), + ) as Citation; +} diff --git a/src/index.test.ts b/src/index.test.ts index d89eb2b..faea67e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -119,7 +119,7 @@ describe("Perplexity MCP Server", () => { const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toBe("This is a test response"); + expect(result.textWithCitationsFooter).toBe("This is a test response"); expect(global.fetch).toHaveBeenCalledWith( AGENT_URL, expect.objectContaining({ @@ -157,8 +157,8 @@ describe("Perplexity MCP Server", () => { const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toContain("Answer citing sources[3][1]."); - expect(result).toContain( + expect(result.textWithCitationsFooter).toContain("Answer citing sources[3][1]."); + expect(result.textWithCitationsFooter).toContain( "\n\nCitations:\n[1] https://example.com/first\n[2] https://example.com/second\n[3] https://example.com/third\n" ); }); @@ -232,7 +232,7 @@ describe("Perplexity MCP Server", () => { { onProgress: (update) => updates.push(update) } ); - expect(result).toBe("done"); + expect(result.textWithCitationsFooter).toBe("done"); expect(updates.map((u) => u.message)).toEqual([ "Searching: battery production | solid state timeline", "Reading 2 search results", @@ -444,7 +444,7 @@ describe("Perplexity MCP Server", () => { const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toBe("late close"); + expect(result.textWithCitationsFooter).toBe("late close"); expect(cancelCalls).toEqual([]); }); @@ -504,8 +504,8 @@ describe("Perplexity MCP Server", () => { const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toContain("Answer."); - expect(result).toContain("[2] https://example.com/real"); + expect(result.textWithCitationsFooter).toContain("Answer."); + expect(result.textWithCitationsFooter).toContain("[2] https://example.com/real"); }); it("should wrap schema errors from a malformed completed response", async () => { @@ -557,7 +557,7 @@ describe("Perplexity MCP Server", () => { .mockResolvedValue({ ok: true, body: stream } as unknown as Response); const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toBe("recovered"); + expect(result.textWithCitationsFooter).toBe("recovered"); }); it("should decode multi-byte characters split across stream chunks", async () => { @@ -577,7 +577,7 @@ describe("Perplexity MCP Server", () => { const result = await performAgentResponse(TEST_MESSAGES, "fast"); - expect(result).toBe("Response with émojis 🎉 and unicode ñ"); + expect(result.textWithCitationsFooter).toBe("Response with émojis 🎉 and unicode ñ"); }); it("should pass through multi-turn conversations", async () => { @@ -621,8 +621,8 @@ describe("Perplexity MCP Server", () => { const result = await performSearch("test query", 10, 1024); - expect(result).toContain("Found 1 search results"); - expect(result).toContain("Search Result"); + expect(result.text).toContain("Found 1 search results"); + expect(result.text).toContain("Search Result"); expect(global.fetch).toHaveBeenCalledWith( SEARCH_URL, expect.objectContaining({ diff --git a/src/server.test.ts b/src/server.test.ts index 66a45f2..06fc863 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -394,3 +394,181 @@ describe("createPerplexityServer tools/list schemas", () => { await server.close(); }); }); + +describe("createPerplexityServer SEP-3094 tool results", () => { + it("replaces the legacy footer when the client renders agent citations", async () => { + const originalFetch = global.fetch; + const completed = { + type: "response.completed", + response: { + id: "resp_test", + status: "completed", + model: "test", + output: [ + { + type: "search_results", + results: [ + { + id: 1, + title: "Source", + url: "https://example.com/source", + snippet: "Supporting passage", + }, + ], + }, + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "Supported claim[1].", + }, + ], + }, + ], + }, + }; + global.fetch = vi.fn().mockResolvedValue( + new Response( + `event: response.completed\ndata: ${JSON.stringify(completed)}\n\n`, + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); + const { createPerplexityServer } = await import("./server.js"); + const server = createPerplexityServer(undefined, { + apiKey: () => "pplx-test", + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "perplexity_ask", + arguments: { + messages: [{ role: "user", content: "Question" }], + }, + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + citations: { render: {} }, + }, + }, + }); + + expect(result.content).toEqual([ + { type: "text", text: "Supported claim[1]." }, + ]); + expect(JSON.stringify(result.content)).not.toContain("Citations:"); + expect((result as unknown as { citations?: unknown[] }).citations).toEqual([ + { + id: "1", + name: "Source", + publisher: { name: "example" }, + url: + "https://example.com/source#:~:text=Supporting%20passage", + text: "Supporting passage", + }, + ]); + } finally { + global.fetch = originalFetch; + await client.close(); + await server.close(); + } + }); + + it("returns top-level citations when the client advertises support", async () => { + const originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + title: "Source", + url: "https://example.com/source", + snippet: "Supporting passage", + date: "2026-09-23", + }, + ], + }), + } as Response); + + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); + const { createPerplexityServer } = await import("./server.js"); + const server = createPerplexityServer(undefined, { + apiKey: () => "pplx-test", + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "perplexity_search", + arguments: { query: "test" }, + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + citations: { render: {} }, + }, + }, + }); + + expect((result as unknown as { citations?: unknown[] }).citations).toEqual([ + { + name: "Source", + publisher: { name: "example" }, + url: + "https://example.com/source#:~:text=Supporting%20passage", + text: "Supporting passage", + datePublished: "2026-09-23", + }, + ]); + } finally { + global.fetch = originalFetch; + await client.close(); + await server.close(); + } + }); + + it("omits citations for clients that do not advertise support", async () => { + const originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { title: "Source", url: "https://example.com/source" }, + ], + }), + } as Response); + + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); + const { createPerplexityServer } = await import("./server.js"); + const server = createPerplexityServer(undefined, { + apiKey: () => "pplx-test", + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "perplexity_search", + arguments: { query: "test" }, + }); + + expect((result as unknown as { citations?: unknown[] }).citations).toBeUndefined(); + } finally { + global.fetch = originalFetch; + await client.close(); + await server.close(); + } + }); +}); diff --git a/src/server.ts b/src/server.ts index c78a160..54399ad 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,12 @@ import type { UndiciRequestOptions } from "./types.js"; import { AgentResponseSchema, SearchResponseSchema } from "./validation.js"; +import { + citationsFromAgentResponse, + citationsFromSearchResponse, + getCitationCapabilities, + type Citation, +} from "./citations.js"; export type { ApiKeyProvider, PerplexityServerOptions } from "./types.js"; @@ -334,8 +340,10 @@ export function extractAgentText(response: AgentResponse): string { * missing or ambiguous, all unique source URLs are appended with positional * numbering instead. */ -export function formatAgentResponseText(response: AgentResponse): string { - const text = extractAgentText(response); +export function formatAgentResponseText( + response: AgentResponse, + text: string = extractAgentText(response), +): string { const entries: Array<{ id?: number | null; url: string }> = []; const urlById = new Map(); @@ -400,6 +408,12 @@ function buildWebSearchTool(options?: AgentToolOptions): Record return Object.keys(tool).length > 1 ? tool : undefined; } +interface AgentResponseResult { + text: string; + textWithCitationsFooter: string; + citations: Citation[]; +} + export async function performAgentResponse( messages: Message[], preset: string, @@ -407,7 +421,7 @@ export async function performAgentResponse( options?: AgentToolOptions, hooks?: AgentCallHooks, apiKey?: ApiKeyProvider, -): Promise { +): Promise { const webSearchTool = buildWebSearchTool(options); const body: Record = { @@ -441,7 +455,12 @@ export async function performAgentResponse( try { const response = await makeApiRequest("v1/agent", body, serviceOrigin, deadline.signal, apiKey); const agentResponse = await consumeAgentStream(response, hooks, serviceOrigin, deadline.signal, apiKey); - return formatAgentResponseText(agentResponse); + const text = extractAgentText(agentResponse); + return { + text, + textWithCitationsFooter: formatAgentResponseText(agentResponse, text), + citations: citationsFromAgentResponse(agentResponse), + }; } catch (error) { if (hooks?.signal?.aborted) { throw new Error("Request cancelled"); @@ -478,6 +497,11 @@ export function formatSearchResults(data: SearchResponse): string { return formattedResults; } +interface SearchResponseResult { + text: string; + citations: Citation[]; +} + export async function performSearch( query: string, maxResults: number = 10, @@ -486,7 +510,7 @@ export async function performSearch( filters?: SearchOptions, serviceOrigin?: string, apiKey?: ApiKeyProvider, -): Promise { +): Promise { const body: Record = { query: query, max_results: maxResults, @@ -507,18 +531,45 @@ export async function performSearch( throw new Error(`Failed to parse JSON response from Perplexity Search API: ${error}`); } - return formatSearchResults(data); + return { + text: formatSearchResults(data), + citations: citationsFromSearchResponse(data), + }; } interface ToolExtra { signal?: AbortSignal; - _meta?: { progressToken?: string | number }; + _meta?: { + progressToken?: string | number; + [key: string]: unknown; + }; sendNotification?: (notification: { method: string; params: Record; }) => Promise; } +function withCitations>( + result: T, + citations: Citation[], + extra: ToolExtra | undefined, +): T & { citations?: Citation[] } { + const capabilities = getCitationCapabilities(extra); + if (!capabilities.supported || citations.length === 0) { + return result; + } + return { ...result, citations }; +} + +function selectAgentText( + result: AgentResponseResult, + extra: ToolExtra | undefined, +): string { + return getCitationCapabilities(extra).render + ? result.text + : result.textWithCitationsFooter; +} + function buildHooks(extra: ToolExtra | undefined): AgentCallHooks { const progressToken = extra?._meta?.progressToken; const sendNotification = extra?.sendNotification; @@ -629,10 +680,15 @@ export function createPerplexityServer(serviceOrigin?: string, serverOptions?: P buildHooks(extra), serverOptions?.apiKey, ); - return { - content: [{ type: "text" as const, text: result }], - structuredContent: { response: result }, - }; + const text = selectAgentText(result, extra); + return withCitations( + { + content: [{ type: "text" as const, text }], + structuredContent: { response: result.textWithCitationsFooter }, + }, + result.citations, + extra, + ); } ); @@ -666,10 +722,15 @@ export function createPerplexityServer(serviceOrigin?: string, serverOptions?: P buildHooks(extra), serverOptions?.apiKey, ); - return { - content: [{ type: "text" as const, text: result }], - structuredContent: { response: result }, - }; + const text = selectAgentText(result, extra); + return withCitations( + { + content: [{ type: "text" as const, text }], + structuredContent: { response: result.textWithCitationsFooter }, + }, + result.citations, + extra, + ); } ); @@ -713,10 +774,15 @@ export function createPerplexityServer(serviceOrigin?: string, serverOptions?: P buildHooks(extra), serverOptions?.apiKey, ); - return { - content: [{ type: "text" as const, text: result }], - structuredContent: { response: result }, - }; + const text = selectAgentText(result, extra); + return withCitations( + { + content: [{ type: "text" as const, text }], + structuredContent: { response: result.textWithCitationsFooter }, + }, + result.citations, + extra, + ); } ); @@ -756,7 +822,7 @@ export function createPerplexityServer(serviceOrigin?: string, serverOptions?: P destructiveHint: false, }, }, - async (args: any) => { + async (args: any, extra: any) => { const { query, max_results, max_tokens_per_page, country, search_recency_filter, search_domain_filter, search_type } = args as { query: string; max_results?: number; @@ -775,11 +841,23 @@ export function createPerplexityServer(serviceOrigin?: string, serverOptions?: P ...(search_type && { search_type }), }; - const result = await performSearch(query, maxResults, maxTokensPerPage, countryCode, filters, serviceOrigin, serverOptions?.apiKey); - return { - content: [{ type: "text" as const, text: result }], - structuredContent: { results: result }, - }; + const result = await performSearch( + query, + maxResults, + maxTokensPerPage, + countryCode, + filters, + serviceOrigin, + serverOptions?.apiKey, + ); + return withCitations( + { + content: [{ type: "text" as const, text: result.text }], + structuredContent: { results: result.text }, + }, + result.citations, + extra, + ); } );