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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<think>` 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:
Expand Down
221 changes: 221 additions & 0 deletions src/citations.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading