Skip to content
Merged
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
63 changes: 63 additions & 0 deletions docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Private password entry for encrypted PDFs

Status: design proposal, not implemented or claimed as shipped.

## Current boundary

Several PDF tools currently accept a PDF password in their MCP arguments.
Those arguments are model-visible. The existing viewer has no independent
password-entry prompt, and Lumin's browser OAuth flow is provider-specific.
Neither is an encrypted-PDF credential service.

The download tool no longer accepts custom HTTP headers. Authenticated
downloads should happen through the provider's user-facing interface, followed
by opening the saved local file. Lumin authentication continues to use its
existing browser OAuth flow, not credentials supplied in chat.

## Proposed user experience

When an encrypted document needs a password, PDF Tools offers a local unlock
window. The user enters the password there, outside chat. The assistant receives
only a locked/unlocked/expired status for that document. A visible lock action
and short expiry end access. Ordinary non-encrypted workflows remain unchanged.

The implementation must provide a direct user-to-local-server channel.
Sending the password through an app-only MCP tool is not an acceptable shortcut:
it would still be a tool argument. A viewer-only PDF.js prompt would likewise
not unlock extraction or mutation tools running in the server.

## Proposed implementation boundary

- Use a local, user-facing entry surface connected to the already-running
server through authenticated local IPC or a tightly bounded loopback service.
Select and qualify that transport before shipping. For a browser surface,
require an unpredictable session capability, exact Host/Origin checks,
POST-only submission, expiry, no external assets, and no request-body logging.
- Keep credentials in process memory, not command-line arguments, URLs, MCP
calls/results, analytics, logs, temporary plaintext PDFs, or saved profiles.
JavaScript memory and operating-system swap do not provide guaranteed erasure.
- Bind each entry to the canonical path, exact encrypted source hash and file
identity. Revalidate before use; changed bytes invalidate it. Scope access to
the server session and document, not all files with the same password.
- Remove public password arguments when this mechanism is implemented. Reject
legacy password arguments without echoing their values, and resolve the
credential internally before invoking existing bounded PDF.js/QPDF workers.
- Expire entries, cap their count and lifetime, clear them on explicit lock,
disconnect or restart, and avoid transferring them to another session.
- Preserve existing permission checks and unsupported-operation errors.
Unlocking for reading is not permission to modify. Encrypted inputs must
produce encrypted outputs or fail; the existing QPDF reprotection contract
remains in force.

## Qualification before enabling

Test wrong passwords, cancel/expiry/restart, changed sources, concurrent
documents, attempted cross-session access, forged local requests, and old
model-visible password inputs. Assert that passwords never appear in captured
MCP messages, tool errors, logs, process arguments, persisted state or output
artifacts. Test read and write permission distinctions and encrypted-output
preservation, then exercise the real user entry flow in supported desktop hosts.

This proposal requires review and implementation before a submission may claim
password-free encrypted-PDF authentication. The annotation and download changes
do not implement this separate capability.
24 changes: 15 additions & 9 deletions pdf-toolkit-mcp-share/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4772,7 +4772,7 @@ async function listTools(request) {
},
{
name: "fetch_pdf_from_url",
description: "Download a PDF from any URL — the PREFERRED way to grab any PDF from a URL for the user. **Always use this for PDF URLs; do NOT use bash, curl, wget, or WebFetch first.** Those run in Claude's sandbox and fail on many domains (gov sites, enterprise, auth-required URLs); this tool runs on the user's machine with full network access and succeeds where they don't. Returns a local file path that plugs into every other PDF tool here (display_pdf, read_pdf_fields, fill_pdf, validate_pdf, detect_signature_zones, apply_signature, merge_pdfs, etc.). If the user mentions a PDF URL in any way — \"download this,\" \"open this link,\" \"sign this,\" \"fill out\" — this is your first move before any other tool.",
description: "Download a PDF from an HTTP(S) URL to an allowed local directory and return its local path. Enforces download-size, redirect, and network-address checks. Does not provide browser sign-in or accept authentication headers. For sign-in-required files, download through the provider's interface and use the saved local PDF. Do not put passwords, cookies, or access tokens in tool arguments.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -4797,17 +4797,13 @@ async function listTools(request) {
type: "number",
description: "Maximum download size in MB (default: 100). Raise for larger PDFs."
},
headers: {
type: "object",
description: "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.",
additionalProperties: { type: "string" }
},
allow_private_hosts: {
type: "boolean",
description: "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs."
}
},
required: ["url"]
required: ["url"],
additionalProperties: false
},
annotations: {
title: "Fetch PDF from URL",
Expand Down Expand Up @@ -7873,14 +7869,25 @@ async function handleToolCall(request) {
}

case "fetch_pdf_from_url": {
// Hosts may invoke tools without validating inputSchema. Reject legacy
// credential-bearing arguments before filesystem or network activity,
// without reflecting their contents into model-visible errors.
const allowedArguments = new Set([
"url", "filename", "destination_dir", "overwrite",
"expected_output_identity", "max_size_mb", "allow_private_hosts",
]);
if (Object.keys(args).some(key => !allowedArguments.has(key))) {
throw new Error(
"Unsupported download argument. Custom headers and authentication credentials are not accepted. Download sign-in-required PDFs through the provider's interface and use the saved local file.",
);
}
const {
url,
filename,
destination_dir,
overwrite = false,
expected_output_identity,
max_size_mb = 100,
headers,
allow_private_hosts = false,
} = args;

Expand Down Expand Up @@ -7908,7 +7915,6 @@ async function handleToolCall(request) {
overwrite,
expectedOutputIdentity,
maxSizeMb: max_size_mb,
headers: headers || {},
allowPrivateHosts: allow_private_hosts,
assertPathAllowed,
});
Expand Down
4 changes: 2 additions & 2 deletions pdf-toolkit-mcp-share/server/lumin-signing-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([
},
annotations: {
title: "Prepare Lumin Signing Request",
readOnlyHint: true,
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
idempotentHint: false,
openWorldHint: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion scripts/macos-claude-installed-shannon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ if (!process.argv[2] || !process.argv[3]) {
//
const EXPECTED_SOURCE_SHA256 = "6e4e3411984f3edf99dbfe8b941cb5e8a321379ff0cae6ae5c1f592ad8882ca8";
const EXPECTED_MARKDOWN_SHA256 = "b9bd7ca7db26fe807f294565c39de8d4fe9337908afa74cd7e2896a800c92991";
const EXPECTED_TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235";
const EXPECTED_TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386";
const EXPECTED_PAGE_COUNT = 55;
const EXPECTED_GAP_COUNT = 26;
const EXPECTED_REPLACEMENT_CHARACTER_COUNT = 6;
Expand Down
2 changes: 1 addition & 1 deletion scripts/macos-claude-installed-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const textFixture = path.join(fixtureDirectory, "synthetic-text-two-page.pdf");
const rasterFixture = path.join(fixtureDirectory, "synthetic-raster-only.pdf");
const mutationDirectory = path.join(fixtureDirectory, "mutation-output");
const toolNames = [];
const EXPECTED_TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235";
const EXPECTED_TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386";
const ACCESSIBILITY_CONCLUSION_KEYS = Object.freeze([
"certification",
"document_accessibility",
Expand Down
24 changes: 15 additions & 9 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4772,7 +4772,7 @@ async function listTools(request) {
},
{
name: "fetch_pdf_from_url",
description: "Download a PDF from any URL — the PREFERRED way to grab any PDF from a URL for the user. **Always use this for PDF URLs; do NOT use bash, curl, wget, or WebFetch first.** Those run in Claude's sandbox and fail on many domains (gov sites, enterprise, auth-required URLs); this tool runs on the user's machine with full network access and succeeds where they don't. Returns a local file path that plugs into every other PDF tool here (display_pdf, read_pdf_fields, fill_pdf, validate_pdf, detect_signature_zones, apply_signature, merge_pdfs, etc.). If the user mentions a PDF URL in any way — \"download this,\" \"open this link,\" \"sign this,\" \"fill out\" — this is your first move before any other tool.",
description: "Download a PDF from an HTTP(S) URL to an allowed local directory and return its local path. Enforces download-size, redirect, and network-address checks. Does not provide browser sign-in or accept authentication headers. For sign-in-required files, download through the provider's interface and use the saved local PDF. Do not put passwords, cookies, or access tokens in tool arguments.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -4797,17 +4797,13 @@ async function listTools(request) {
type: "number",
description: "Maximum download size in MB (default: 100). Raise for larger PDFs."
},
headers: {
type: "object",
description: "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.",
additionalProperties: { type: "string" }
},
allow_private_hosts: {
type: "boolean",
description: "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs."
}
},
required: ["url"]
required: ["url"],
additionalProperties: false
},
annotations: {
title: "Fetch PDF from URL",
Expand Down Expand Up @@ -7873,14 +7869,25 @@ async function handleToolCall(request) {
}

case "fetch_pdf_from_url": {
// Hosts may invoke tools without validating inputSchema. Reject legacy
// credential-bearing arguments before filesystem or network activity,
// without reflecting their contents into model-visible errors.
const allowedArguments = new Set([
"url", "filename", "destination_dir", "overwrite",
"expected_output_identity", "max_size_mb", "allow_private_hosts",
]);
if (Object.keys(args).some(key => !allowedArguments.has(key))) {
throw new Error(
"Unsupported download argument. Custom headers and authentication credentials are not accepted. Download sign-in-required PDFs through the provider's interface and use the saved local file.",
);
}
const {
url,
filename,
destination_dir,
overwrite = false,
expected_output_identity,
max_size_mb = 100,
headers,
allow_private_hosts = false,
} = args;

Expand Down Expand Up @@ -7908,7 +7915,6 @@ async function handleToolCall(request) {
overwrite,
expectedOutputIdentity,
maxSizeMb: max_size_mb,
headers: headers || {},
allowPrivateHosts: allow_private_hosts,
assertPathAllowed,
});
Expand Down
4 changes: 2 additions & 2 deletions server/lumin-signing-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([
},
annotations: {
title: "Prepare Lumin Signing Request",
readOnlyHint: true,
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
idempotentHint: false,
openWorldHint: false,
},
},
Expand Down
12 changes: 3 additions & 9 deletions test/fixtures/eval/trajectories/tool-contracts.v3.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"name": "pdf-tools",
"version": "0.14.0"
},
"tools_sha256": "09cb0675a8be40e01e9249a6a6f39a5e2fdf7503fab12722e9db564b96424db8",
"tools_sha256": "179dc9340e4554d8c6bbae009ce3fb202e8693ac96fefedc065adb1ab0ea233d",
"tools": [
{
"name": "add_signature_field",
Expand Down Expand Up @@ -879,21 +879,15 @@
"type": "number",
"description": "Maximum download size in MB (default: 100). Raise for larger PDFs."
},
"headers": {
"type": "object",
"description": "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.",
"additionalProperties": {
"type": "string"
}
},
"allow_private_hosts": {
"type": "boolean",
"description": "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs."
}
},
"required": [
"url"
]
],
"additionalProperties": false
}
},
{
Expand Down
8 changes: 8 additions & 0 deletions test/lumin-signing-tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ describe("public Lumin signing workflow", () => {
idempotentHint: false,
openWorldHint: true,
});
expect(LUMIN_SIGNING_TOOL_DEFINITIONS.find(tool => tool.name === "prepare_lumin_request")?.annotations)
.toEqual({
title: "Prepare Lumin Signing Request",
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
});
});

it("starts and completes PKCE without returning or persisting credentials", async () => {
Expand Down
33 changes: 31 additions & 2 deletions test/mcp-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,9 @@ const EXAMPLE_PDF = path.join(REPO_ROOT, "example-fw9.pdf");
// export is explicitly new-file-only. Optional OCR remains absent by default.
// 2026-10-13: default-off header-neutral HTML table option and its coupled
// renderer-profile/output-schema contract. Default conversion bytes unchanged.
const TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235";
// 2026-10-20: prepare_lumin_request is stateful/non-idempotent; fetch is
// neutrally described and no longer exposes arbitrary credential headers.
const TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386";

const CLOSED_READ = Object.freeze({
readOnlyHint: true,
Expand Down Expand Up @@ -286,7 +288,7 @@ const OPEN_IDEMPOTENT_SESSION_ACTION = Object.freeze({
const TOOL_EFFECT_ANNOTATIONS = {
start_lumin_authorization: OPEN_SESSION_ACTION,
finish_lumin_authorization: OPEN_SESSION_ACTION,
prepare_lumin_request: CLOSED_READ,
prepare_lumin_request: CLOSED_SESSION_ACTION,
send_lumin_request: OPEN_NON_IDEMPOTENT_OVERWRITE,
check_lumin_status: OPEN_IDEMPOTENT_SESSION_ACTION,
download_lumin_artifact: OPEN_SESSION_ACTION,
Expand Down Expand Up @@ -612,6 +614,33 @@ describe.each(RUNTIMES)("$name runtime discovery", runtime => {
expect((await client.callTool({ name: "open_pdf_workspace", arguments: { pdf_path: EXAMPLE_PDF } })).isError).toBe(true);
});

it("describes PDF downloads without steering or credential-bearing inputs", () => {
const tool = tools.find(tool => tool.name === "fetch_pdf_from_url");
expect(tool.description).not.toMatch(/preferred|always use|do not use|first move|succeeds where/i);
expect(tool.description).toContain("Does not provide browser sign-in");
expect(tool.inputSchema.properties).not.toHaveProperty("headers");
expect(tool.inputSchema.additionalProperties).toBe(false);
});

it.each([
{ headers: { Authorization: "Bearer synthetic-do-not-reflect" } },
{ headers: { Cookie: "synthetic-do-not-reflect" } },
{ headers: {} },
{ headers: null },
{ access_token: "synthetic-do-not-reflect" },
{ "synthetic-do-not-reflect": true },
])("rejects unadvertised download arguments before URL handling: %j", async extra => {
const before = await fs.readdir(stateRoot);
const result = await client.callTool({
name: "fetch_pdf_from_url",
arguments: { url: "not-a-url", destination_dir: stateRoot, ...extra },
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result)).toContain("Unsupported download argument");
expect(JSON.stringify(result)).not.toContain("synthetic-do-not-reflect");
expect(await fs.readdir(stateRoot)).toEqual(before);
});

it("exposes the same uniquely named, fully annotated tool contract", () => {
expect(tools).toHaveLength(61);
expect(new Set(names(tools)).size).toBe(tools.length);
Expand Down
Loading