diff --git a/README.md b/README.md index 6d167b1..f7f0001 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,56 @@ 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 source titles, URLs, excerpts, and publication dates when available. 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": "Source", + "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..2268e2d --- /dev/null +++ b/src/citations.test.ts @@ -0,0 +1,221 @@ +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", + 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", + 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([ + { url: "https://example.com/one" }, + { 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", + 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", + url: "https://example.com/first#:~:text=First%20result%20excerpt", + text: "First result excerpt", + }, + ]); + }); + + 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..2ba74dc --- /dev/null +++ b/src/citations.ts @@ -0,0 +1,172 @@ +import type { + AgentAnnotation, + AgentResponse, + AgentSearchResult, + SearchResponse, + SearchResult, +} from "./types.js"; + +export interface Citation { + id?: string; + name?: string; + url?: string; + text?: string; + datePublished?: string; +} + +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); + citations.push( + compactCitation({ + ...(idsUsable && typeof result.id === "number" + ? { id: String(result.id) } + : {}), + name: result.title ?? annotation?.title ?? undefined, + url: withTextFragment(result.url, result.snippet ?? undefined), + text: result.snippet ?? undefined, + datePublished: result.date ?? undefined, + }), + ); + } + + return citations; +} + +export function citationsFromSearchResponse(response: SearchResponse): Citation[] { + return dedupeSearchResults(response.results).map((result) => + compactCitation({ + name: result.title, + url: withTextFragment(result.url, result.snippet), + text: result.snippet, + datePublished: result.date, + }), + ); +} + +export function withTextFragment(url: string, text?: string): string { + const quote = text?.replace(/\s+/g, " ").trim().slice(0, 160); + if (!quote) return url; + + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.hash) { + return url; + } + parsed.hash = `:~:text=${encodeURIComponent(quote)}`; + return parsed.toString(); + } catch { + return url; + } +} + +function citationsFromAnnotations(annotations: Iterable): Citation[] { + const citations: Citation[] = []; + for (const annotation of annotations) { + if (!annotation.url && !annotation.title) continue; + citations.push( + compactCitation({ + name: 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; + }); +} + +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..35b52df 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -394,3 +394,179 @@ 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", + 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", + 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, + ); } );