diff --git a/README.md b/README.md index 1803784..819761b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,19 @@ cannot independently prove who typed it. The MCP host must present the destructive tool action, and the agent must pass only the user's actual words and time. Agents must never fabricate either value. -Configure a public OAuth client ID in the extension's **Lumin OAuth Client ID** +This is currently a maintainer-configured integration, not a fully qualified +one-click signup experience. An ordinary user should not need to create a +developer app. If signing is not configured, ask the installation's maintainer; +creating a personal Lumin account alone does not enable the integration. + +Once configured, ask PDF Tools to connect Lumin. Sign in and approve access in +the browser, never by sharing passwords or callback URLs in chat. If you need +an account, create it on Lumin's website. If signup does not return to the +connection before it expires, start the connection again. Connecting does not +upload a PDF, send an invitation, or approve a signature. After reconnecting, +check an existing request rather than sending it again. + +For maintainers: configure a public OAuth client ID in the extension's **Lumin OAuth Client ID** setting. Other stdio hosts may set `LUMIN_OAUTH_CLIENT_ID`; Agent Plugin users may set `luminOAuthClientId` in the plugin's private `config.json`. Register the exact redirect URI `http://127.0.0.1/callback`. The OAuth access token stays only @@ -131,6 +143,12 @@ PKCE workflow. The durable signing-operation store currently supports macOS and Linux. The public Lumin workflow fails closed on Windows until a reviewed ACL-aware state adapter exists. +Lumin also provides its own hosted MCP and an API-key-based local extension. +These are separate connections, not automatically installed, authenticated, or +invoked by PDF Tools. See [Lumin onboarding and integration boundaries](docs/LUMIN_ONBOARDING.md) +for the capability comparison, first-time-user limitations, and attribution +proposal. No signup tracking or analytics reporting is enabled by this work. + ### Page Organization Tools - Merge multiple PDFs into one document diff --git a/docs/LUMIN_ONBOARDING.md b/docs/LUMIN_ONBOARDING.md new file mode 100644 index 0000000..0b48128 --- /dev/null +++ b/docs/LUMIN_ONBOARDING.md @@ -0,0 +1,99 @@ +# Lumin connection and first-time use + +## Current experience + +PDF Tools' direct Lumin integration currently requires a maintainer-configured +public OAuth application ID. It is not a consumer-ready shared application +configuration. No sandbox app ID is supplied as a default. Creating a personal +Lumin account does not fix missing installation configuration. + +Once configured, `start_lumin_authorization` opens Lumin's authorization page +and returns instructions; `finish_lumin_authorization` completes the connection. +Account creation, passwords, and consent belong in Lumin's browser experience, +not MCP arguments or chat. A new user may need to create an account on Lumin's +website and restart connection if signup does not preserve the authorization +journey. Seamless signup continuation has not been qualified. + +The local callback expires after five minutes. An expired or unsuccessful +connection can be restarted without uploading a PDF or sending an invitation. +Tokens remain in memory and a restart or token expiry requires reconnection. +Reconnect and poll an existing signing request; do not send a replacement. +Connecting an account never authorizes the separate document-send action. + +## Lumin's own MCP: complement, not an automatic replacement + +Research checked the official repository at +[`7c5ece34270a62752fc3298040b5414a95288999`](https://github.com/luminpdf/lumin-mcp-server/tree/7c5ece34270a62752fc3298040b5414a95288999) +and current developer documentation on 2026-09-05. Source and documentation +inspection is not a live authenticated compatibility test. + +| Offering | What the inspected evidence establishes | Boundary | +| --- | --- | --- | +| Lumin local extension | Its manifest requires an API key; `stdio.js` passes that key to its tool server. Seven tools cover user/workspace information, upload, Markdown conversion, and send/status/cancel. | It does not remove account or credential setup. | +| Lumin hosted MCP | Official connection docs advertise `https://mcp.luminpdf.com/mcp`. The current tool reference lists 14 tools, including templates and agreement generation/download. | Hosted implementation, authentication persistence, and signup continuation were not executed or proven from the older local repository. | +| PDF Tools direct integration | Local PDF preparation, exact recipient preview and confirmation, direct upload, durable request outcome, status polling, and validated local artifact saving. | Separately configured account connection. No automatic federation or token sharing with Lumin's MCP. | + +The inspected repository and published MCP tool list contain no account-signup +or attribution-reporting tool. That is not proof that Lumin lacks internal +signup or reporting services. The repository contains an OAuth admin helper +for creating OAuth clients, not user accounts; it requires server-side admin +configuration and must not be copied into a desktop client. + +The hosted tool reference describes upload and signing from file URLs. A +local PDF path is not a remotely accessible URL. Do not expose a local file +server, upload to an intermediary, or publish a document merely to bridge the +two MCP servers. A future handoff needs an explicit private transfer contract +and the user's document-send consent. Neither connection's tokens, session +IDs, nor approval receipts are transferable to the other by assumption. + +Prefer evaluating Lumin's hosted MCP as a companion for cloud-native templates, +workspace browsing, and cancellation before duplicating those tools locally. +The current integration does not install or call it. Two co-installed servers +must not both send the same request when one has already submitted it or has +an uncertain outcome. + +## Attribution proposal, not enabled telemetry + +The preferred first step is provider-side reporting against a dedicated, +ODA-owned production OAuth application identity. Confirm with Lumin that an +app registered in the owner's workspace can serve unrelated users/workspaces, +which workspace bears quotas/charges, and whether reporting can distinguish +PDF Tools traffic. Do not ship the test app as a production default. + +Useful proposed measures are attributable new accounts, connected accounts, +unique signature requests sent, and completed/canceled/failed requests. A +successful authorization is not necessarily a new account. Polls are not new +requests. Provider outcomes must be deduplicated by request and state, rather +than counting every tool call or status poll as engagement. + +The public docs inspected do not establish a signup referral mechanism or +per-app analytics dashboard. Lumin must confirm attribution across signup and +authorization. Do not invent referral parameters or repurpose OAuth `state`, +which is reserved here for connection security. + +Local preparation, abandoned local attempts, and local errors are not covered +by provider-side reporting. Any future client analytics requires a separately +reviewed purpose, minimized schema, user-facing disclosure/control, retention, +and recipient. Do not report PDF content, prompts, paths, filenames, signer +names/emails, signatures, tokens, callback URLs, or signed download URLs as +analytics. No analytics collector, event emission, tracking identifier, signup +API, or additional network dependency is enabled by this change. + +App webhooks are documented as private/server-app only and are not a reporting +shortcut for the current public PKCE desktop client. Server-side analytics +and private webhooks would be a separate architecture, not a reason to put +server secrets into the extension. + +## Sources and qualification + +- [Official repository manifest](https://github.com/luminpdf/lumin-mcp-server/blob/7c5ece34270a62752fc3298040b5414a95288999/manifest.json) +- [Official local entry point](https://github.com/luminpdf/lumin-mcp-server/blob/7c5ece34270a62752fc3298040b5414a95288999/stdio.js) +- [Official hosted connection](https://developers.luminpdf.com/tabs/mcp/connect) +- [Official current MCP tools](https://developers.luminpdf.com/tabs/mcp/supportedTool) +- [OAuth and public-client constraints](https://developers.luminpdf.com/tabs/guides/authentication/oauth2) +- [App-webhook restrictions](https://developers.luminpdf.com/tabs/guides/webhooks/app-webhooks) + +The published redirect-URI prose still conflicts with the provider-confirmed, +tested loopback behavior. This research does not change the existing exact +`http://127.0.0.1/callback` registration or ephemeral-port implementation. +Do not infer free API allowance from Lumin's consumer web-plan pricing. diff --git a/pdf-toolkit-mcp-share/server/index.js b/pdf-toolkit-mcp-share/server/index.js index 139b57c..7486407 100755 --- a/pdf-toolkit-mcp-share/server/index.js +++ b/pdf-toolkit-mcp-share/server/index.js @@ -121,6 +121,7 @@ import { LUMIN_SIGNING_TOOL_DEFINITIONS, LUMIN_SIGNING_TOOL_NAMES, createLuminSigningToolHandler, + formatLuminSigningToolText, } from "./lumin-signing-tools.js"; export const READ_CONTENT_ROUTING_GUIDANCE = @@ -5033,17 +5034,7 @@ async function handleToolCall(request) { try { if (LUMIN_SIGNING_TOOL_NAME_SET.has(name)) { const structuredContent = await handleLuminSigningTool(name, args ?? {}); - const summary = name === "start_lumin_authorization" - ? "Opened Lumin authorization in the browser." - : name === "finish_lumin_authorization" - ? "Connected the Lumin account for this PDF Tools session." - : name === "prepare_lumin_request" - ? "Prepared the Lumin signing request locally. Nothing was sent." - : name === "send_lumin_request" - ? `Created Lumin signing request ${structuredContent.signature_request_id}.` - : name === "check_lumin_status" - ? `Lumin signing status: ${structuredContent.provider_status}.` - : `Downloaded the Lumin ${structuredContent.file_type} PDF to ${structuredContent.pdf_path}.`; + const summary = formatLuminSigningToolText(name, structuredContent); return { content: [{ type: "text", text: summary }], structuredContent, diff --git a/pdf-toolkit-mcp-share/server/lumin-signing-tools.js b/pdf-toolkit-mcp-share/server/lumin-signing-tools.js index c39726f..2f5ef91 100644 --- a/pdf-toolkit-mcp-share/server/lumin-signing-tools.js +++ b/pdf-toolkit-mcp-share/server/lumin-signing-tools.js @@ -53,7 +53,7 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ { name: "start_lumin_authorization", description: - "Start a secure browser login to Lumin using OAuth PKCE. This opens Lumin in the user's browser and returns an opaque local authorization session ID. It does not send a PDF or create a signing request. Call finish_lumin_authorization after the user finishes in the browser.", + "Connect a Lumin account through secure browser login using OAuth PKCE. Follow the returned next_step instructions. Account creation and passwords belong on Lumin's website, never in tool arguments or chat. If this installation is not configured, ask its maintainer to enable the integration rather than asking the user to create a developer app. This does not send a PDF or create a signing request. Call finish_lumin_authorization after the user finishes in the browser.", inputSchema: { type: "object", properties: {}, @@ -213,6 +213,25 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ }, ]); +// Keep connection guidance in text as well as structuredContent for clients +// that primarily expose the text content of tool results to users/models. +export function formatLuminSigningToolText(name, result) { + const summary = name === "start_lumin_authorization" + ? "Opened Lumin authorization in the browser." + : name === "finish_lumin_authorization" + ? "Connected the Lumin account for this PDF Tools session." + : name === "prepare_lumin_request" + ? "Prepared the Lumin signing request locally. Nothing was sent." + : name === "send_lumin_request" + ? `Created Lumin signing request ${result.signature_request_id}.` + : name === "check_lumin_status" + ? `Lumin signing status: ${result.provider_status}.` + : `Downloaded the Lumin ${result.file_type} PDF to ${result.pdf_path}.`; + return (name === "start_lumin_authorization" || name === "finish_lumin_authorization") + ? `${summary} ${result.next_step}` + : summary; +} + function toolError(code, message) { const error = new Error(`${code}: ${message}`); error.code = code; @@ -429,7 +448,7 @@ export function createLuminSigningToolHandler({ if (typeof clientId !== "string" || !clientId || clientId.includes("${")) { throw toolError( "LUMIN_OAUTH_NOT_CONFIGURED", - "Lumin signing is not configured. Add a public Lumin OAuth client ID to PDF Tools settings and restart PDF Tools.", + "Lumin signing is not enabled in this PDF Tools installation. Ask its maintainer to configure the public Lumin OAuth app. Creating a personal Lumin account will not fix this installation setting. Do not share a password, API key, or client secret in chat. Local PDF tools remain available.", ); } if (typeof stateRoot !== "string" || !path.isAbsolute(stateRoot)) { @@ -442,7 +461,7 @@ export function createLuminSigningToolHandler({ const connection = connections.get(id); if (!connection || connection.expiresAtMs <= currentMs() + 30_000) { connections.delete(id); - throw toolError("LUMIN_AUTHORIZATION_REQUIRED", "Connect the Lumin account again before continuing."); + throw toolError("LUMIN_AUTHORIZATION_REQUIRED", "Connect the Lumin account again with start_lumin_authorization, then finish_lumin_authorization. This does not resend any signing request. Use check_lumin_status for an existing request, not send_lumin_request."); } return connection; } @@ -482,7 +501,7 @@ export function createLuminSigningToolHandler({ await openExternal(session.authorizationUrl); } catch { await session.close(); - throw toolError("LUMIN_BROWSER_OPEN_FAILED", "PDF Tools could not open the Lumin authorization page."); + throw toolError("LUMIN_BROWSER_OPEN_FAILED", "PDF Tools could not open Lumin in your browser. Check your default browser, then try start_lumin_authorization again. No PDF or signing request was sent."); } pendingAuthorizations.set(id, { session, @@ -495,6 +514,7 @@ export function createLuminSigningToolHandler({ callback_expires_at: new Date(startedAtMs + AUTHORIZATION_TIMEOUT_MS).toISOString(), browser_opened: true, pdf_sent: false, + next_step: "Sign in on the Lumin page opened in your browser and review its access request. If you do not have an account, create one on Lumin's website, then return here. If signup does not return to this connection before it expires, run start_lumin_authorization again. Once you approve access in the browser, call finish_lumin_authorization with this session ID. Never paste passwords or browser callback URLs into chat.", }; } @@ -505,7 +525,8 @@ export function createLuminSigningToolHandler({ const pending = pendingAuthorizations.get(id); if (!pending || pending.expiresAtMs <= currentMs()) { pendingAuthorizations.delete(id); - throw toolError("LUMIN_AUTHORIZATION_SESSION_INVALID", "The Lumin authorization session is missing or expired."); + if (pending) await pending.session.close().catch(() => {}); + throw toolError("LUMIN_AUTHORIZATION_SESSION_INVALID", "This Lumin connection attempt is missing or expired. Run start_lumin_authorization again, finish in the browser, then call finish_lumin_authorization with the new session ID. No signing request was sent."); } if (pending.completionInProgress) { throw toolError("LUMIN_AUTHORIZATION_PENDING", "Lumin authorization is still waiting for the browser callback."); @@ -542,7 +563,7 @@ export function createLuminSigningToolHandler({ throw sanitizedDependencyError( error, "LUMIN_OAUTH_TOKEN_REQUEST_FAILED", - "Lumin authorization could not be completed safely.", + "Lumin authorization was not completed. You can leave it disconnected or run start_lumin_authorization again when ready. No PDF or signing request was sent. Do not paste passwords or browser callback URLs into chat.", ); } finally { if (!pendingAuthorizations.has(id)) await pending.session.close().catch(() => {}); @@ -565,6 +586,7 @@ export function createLuminSigningToolHandler({ access_token_persisted: false, refresh_token_persisted: false, pdf_sent: false, + next_step: "Lumin is connected, but no PDF or signing request has been sent. For a new request, prepare the PDF with prepare_signing_packet, preview it with prepare_lumin_request, and obtain the user's exact sending confirmation before send_lumin_request. For an existing request, use check_lumin_status or download_lumin_artifact without sending again.", }; } diff --git a/pdf-toolkit-mcp-share/server/output-schemas.js b/pdf-toolkit-mcp-share/server/output-schemas.js index b64fa4a..79e7e64 100644 --- a/pdf-toolkit-mcp-share/server/output-schemas.js +++ b/pdf-toolkit-mcp-share/server/output-schemas.js @@ -1803,6 +1803,7 @@ export const TOOL_SUCCESS_OUTPUT_SCHEMAS = Object.freeze({ }), start_lumin_authorization: object({ status: { const: "awaiting_user_authorization" }, + next_step: string, provider: { const: "lumin_sign" }, authorization_session_id: string, callback_expires_at: string, @@ -1811,6 +1812,7 @@ export const TOOL_SUCCESS_OUTPUT_SCHEMAS = Object.freeze({ }), finish_lumin_authorization: object({ status: { const: "connected" }, + next_step: string, provider: { const: "lumin_sign" }, authorization_session_id: string, token_expires_at: string, diff --git a/server/index.js b/server/index.js index 139b57c..7486407 100755 --- a/server/index.js +++ b/server/index.js @@ -121,6 +121,7 @@ import { LUMIN_SIGNING_TOOL_DEFINITIONS, LUMIN_SIGNING_TOOL_NAMES, createLuminSigningToolHandler, + formatLuminSigningToolText, } from "./lumin-signing-tools.js"; export const READ_CONTENT_ROUTING_GUIDANCE = @@ -5033,17 +5034,7 @@ async function handleToolCall(request) { try { if (LUMIN_SIGNING_TOOL_NAME_SET.has(name)) { const structuredContent = await handleLuminSigningTool(name, args ?? {}); - const summary = name === "start_lumin_authorization" - ? "Opened Lumin authorization in the browser." - : name === "finish_lumin_authorization" - ? "Connected the Lumin account for this PDF Tools session." - : name === "prepare_lumin_request" - ? "Prepared the Lumin signing request locally. Nothing was sent." - : name === "send_lumin_request" - ? `Created Lumin signing request ${structuredContent.signature_request_id}.` - : name === "check_lumin_status" - ? `Lumin signing status: ${structuredContent.provider_status}.` - : `Downloaded the Lumin ${structuredContent.file_type} PDF to ${structuredContent.pdf_path}.`; + const summary = formatLuminSigningToolText(name, structuredContent); return { content: [{ type: "text", text: summary }], structuredContent, diff --git a/server/lumin-signing-tools.js b/server/lumin-signing-tools.js index c39726f..2f5ef91 100644 --- a/server/lumin-signing-tools.js +++ b/server/lumin-signing-tools.js @@ -53,7 +53,7 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ { name: "start_lumin_authorization", description: - "Start a secure browser login to Lumin using OAuth PKCE. This opens Lumin in the user's browser and returns an opaque local authorization session ID. It does not send a PDF or create a signing request. Call finish_lumin_authorization after the user finishes in the browser.", + "Connect a Lumin account through secure browser login using OAuth PKCE. Follow the returned next_step instructions. Account creation and passwords belong on Lumin's website, never in tool arguments or chat. If this installation is not configured, ask its maintainer to enable the integration rather than asking the user to create a developer app. This does not send a PDF or create a signing request. Call finish_lumin_authorization after the user finishes in the browser.", inputSchema: { type: "object", properties: {}, @@ -213,6 +213,25 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ }, ]); +// Keep connection guidance in text as well as structuredContent for clients +// that primarily expose the text content of tool results to users/models. +export function formatLuminSigningToolText(name, result) { + const summary = name === "start_lumin_authorization" + ? "Opened Lumin authorization in the browser." + : name === "finish_lumin_authorization" + ? "Connected the Lumin account for this PDF Tools session." + : name === "prepare_lumin_request" + ? "Prepared the Lumin signing request locally. Nothing was sent." + : name === "send_lumin_request" + ? `Created Lumin signing request ${result.signature_request_id}.` + : name === "check_lumin_status" + ? `Lumin signing status: ${result.provider_status}.` + : `Downloaded the Lumin ${result.file_type} PDF to ${result.pdf_path}.`; + return (name === "start_lumin_authorization" || name === "finish_lumin_authorization") + ? `${summary} ${result.next_step}` + : summary; +} + function toolError(code, message) { const error = new Error(`${code}: ${message}`); error.code = code; @@ -429,7 +448,7 @@ export function createLuminSigningToolHandler({ if (typeof clientId !== "string" || !clientId || clientId.includes("${")) { throw toolError( "LUMIN_OAUTH_NOT_CONFIGURED", - "Lumin signing is not configured. Add a public Lumin OAuth client ID to PDF Tools settings and restart PDF Tools.", + "Lumin signing is not enabled in this PDF Tools installation. Ask its maintainer to configure the public Lumin OAuth app. Creating a personal Lumin account will not fix this installation setting. Do not share a password, API key, or client secret in chat. Local PDF tools remain available.", ); } if (typeof stateRoot !== "string" || !path.isAbsolute(stateRoot)) { @@ -442,7 +461,7 @@ export function createLuminSigningToolHandler({ const connection = connections.get(id); if (!connection || connection.expiresAtMs <= currentMs() + 30_000) { connections.delete(id); - throw toolError("LUMIN_AUTHORIZATION_REQUIRED", "Connect the Lumin account again before continuing."); + throw toolError("LUMIN_AUTHORIZATION_REQUIRED", "Connect the Lumin account again with start_lumin_authorization, then finish_lumin_authorization. This does not resend any signing request. Use check_lumin_status for an existing request, not send_lumin_request."); } return connection; } @@ -482,7 +501,7 @@ export function createLuminSigningToolHandler({ await openExternal(session.authorizationUrl); } catch { await session.close(); - throw toolError("LUMIN_BROWSER_OPEN_FAILED", "PDF Tools could not open the Lumin authorization page."); + throw toolError("LUMIN_BROWSER_OPEN_FAILED", "PDF Tools could not open Lumin in your browser. Check your default browser, then try start_lumin_authorization again. No PDF or signing request was sent."); } pendingAuthorizations.set(id, { session, @@ -495,6 +514,7 @@ export function createLuminSigningToolHandler({ callback_expires_at: new Date(startedAtMs + AUTHORIZATION_TIMEOUT_MS).toISOString(), browser_opened: true, pdf_sent: false, + next_step: "Sign in on the Lumin page opened in your browser and review its access request. If you do not have an account, create one on Lumin's website, then return here. If signup does not return to this connection before it expires, run start_lumin_authorization again. Once you approve access in the browser, call finish_lumin_authorization with this session ID. Never paste passwords or browser callback URLs into chat.", }; } @@ -505,7 +525,8 @@ export function createLuminSigningToolHandler({ const pending = pendingAuthorizations.get(id); if (!pending || pending.expiresAtMs <= currentMs()) { pendingAuthorizations.delete(id); - throw toolError("LUMIN_AUTHORIZATION_SESSION_INVALID", "The Lumin authorization session is missing or expired."); + if (pending) await pending.session.close().catch(() => {}); + throw toolError("LUMIN_AUTHORIZATION_SESSION_INVALID", "This Lumin connection attempt is missing or expired. Run start_lumin_authorization again, finish in the browser, then call finish_lumin_authorization with the new session ID. No signing request was sent."); } if (pending.completionInProgress) { throw toolError("LUMIN_AUTHORIZATION_PENDING", "Lumin authorization is still waiting for the browser callback."); @@ -542,7 +563,7 @@ export function createLuminSigningToolHandler({ throw sanitizedDependencyError( error, "LUMIN_OAUTH_TOKEN_REQUEST_FAILED", - "Lumin authorization could not be completed safely.", + "Lumin authorization was not completed. You can leave it disconnected or run start_lumin_authorization again when ready. No PDF or signing request was sent. Do not paste passwords or browser callback URLs into chat.", ); } finally { if (!pendingAuthorizations.has(id)) await pending.session.close().catch(() => {}); @@ -565,6 +586,7 @@ export function createLuminSigningToolHandler({ access_token_persisted: false, refresh_token_persisted: false, pdf_sent: false, + next_step: "Lumin is connected, but no PDF or signing request has been sent. For a new request, prepare the PDF with prepare_signing_packet, preview it with prepare_lumin_request, and obtain the user's exact sending confirmation before send_lumin_request. For an existing request, use check_lumin_status or download_lumin_artifact without sending again.", }; } diff --git a/server/output-schemas.js b/server/output-schemas.js index b64fa4a..79e7e64 100644 --- a/server/output-schemas.js +++ b/server/output-schemas.js @@ -1803,6 +1803,7 @@ export const TOOL_SUCCESS_OUTPUT_SCHEMAS = Object.freeze({ }), start_lumin_authorization: object({ status: { const: "awaiting_user_authorization" }, + next_step: string, provider: { const: "lumin_sign" }, authorization_session_id: string, callback_expires_at: string, @@ -1811,6 +1812,7 @@ export const TOOL_SUCCESS_OUTPUT_SCHEMAS = Object.freeze({ }), finish_lumin_authorization: object({ status: { const: "connected" }, + next_step: string, provider: { const: "lumin_sign" }, authorization_session_id: string, token_expires_at: string, diff --git a/test/fixtures/eval/extraction/phase1/layout-occurrence-oracle.v1.json b/test/fixtures/eval/extraction/phase1/layout-occurrence-oracle.v1.json index 358d010..b4e32dc 100644 --- a/test/fixtures/eval/extraction/phase1/layout-occurrence-oracle.v1.json +++ b/test/fixtures/eval/extraction/phase1/layout-occurrence-oracle.v1.json @@ -85,8 +85,8 @@ { "role": "output_schemas_module", "path": "server/output-schemas.js", - "bytes": 72478, - "sha256": "ffbedd57b86688448dc03d3dd4ea05f31cce62c902497f696d9146e172e147f5" + "bytes": 72524, + "sha256": "dae2e2d3ac168c3be5f3ad1f72a6dce6cb9db0a3649b7f906ea2d8922386b1a0" }, { "role": "package_json", @@ -131,7 +131,7 @@ "sha256": "ef40501b2afbe4cd2adfa80480344783bbec1a00e8e9850086a5d044231d1f53" } ], - "validator_source_set_sha256": "9008f85b8712d64b5505c72c3b5b3a60fc4f861f46e7d0070c6a2814168fda80", + "validator_source_set_sha256": "98e57166bd20cc226c4ccc8eaf25b456ec5eae4b0b8bd61d3ffd58be4ec22b3a", "pdfjs_version": "5.4.624", "generation_contract": { "source_path": "source.pdf", diff --git a/test/lumin-signing-tools.test.js b/test/lumin-signing-tools.test.js index a928bad..6c45aa0 100644 --- a/test/lumin-signing-tools.test.js +++ b/test/lumin-signing-tools.test.js @@ -4,6 +4,7 @@ import { LUMIN_SIGNING_DISCLOSURE, LUMIN_SIGNING_TOOL_DEFINITIONS, createLuminSigningToolHandler, + formatLuminSigningToolText, } from "../server/lumin-signing-tools.js"; import { validateStructuredToolResult } from "../server/output-schemas.js"; import { LUMIN_SIGN_V1_DIRECT_UPLOAD_CONFIRMATION } from "../server/lumin-sign-v1-transport.js"; @@ -231,6 +232,10 @@ describe("public Lumin signing workflow", () => { }); expect(dependencies.openExternal).toHaveBeenCalledWith("https://auth.luminpdf.com/oauth2/auth?opaque=one"); expect(JSON.stringify(started)).not.toContain("auth.luminpdf.com"); + expect(started.next_step).toContain("create one on Lumin's website"); + expect(started.next_step).toContain("Never paste passwords"); + expect(formatLuminSigningToolText("start_lumin_authorization", started)).toContain(started.next_step); + expect(formatLuminSigningToolText("start_lumin_authorization", started)).not.toContain(started.authorization_session_id); expectValidStructuredOutput("start_lumin_authorization", started); const completed = await handle("finish_lumin_authorization", { authorization_session_id: started.authorization_session_id, @@ -243,6 +248,12 @@ describe("public Lumin signing workflow", () => { pdf_sent: false, }); expect(JSON.stringify(completed)).not.toContain("token-that-must-never-escape"); + expect(completed.next_step).toContain("no PDF or signing request has been sent"); + expect(completed.next_step).toContain("check_lumin_status"); + expect(formatLuminSigningToolText("finish_lumin_authorization", completed)).toContain(completed.next_step); + expect(formatLuminSigningToolText("finish_lumin_authorization", completed)).not.toContain(ACCESS_TOKEN); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); + expect(dependencies.readPreparedPdf).not.toHaveBeenCalled(); expectValidStructuredOutput("finish_lumin_authorization", completed); }); @@ -250,9 +261,91 @@ describe("public Lumin signing workflow", () => { const { dependencies, handle } = workflow({ clientId: null }); await expect(handle("start_lumin_authorization", {})).rejects.toMatchObject({ code: "LUMIN_OAUTH_NOT_CONFIGURED", + message: expect.stringContaining("Creating a personal Lumin account will not fix"), }); expect(dependencies.createOAuthSession).not.toHaveBeenCalled(); expect(dependencies.openExternal).not.toHaveBeenCalled(); + expect(dependencies.fetchImpl).not.toHaveBeenCalled(); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); + }); + + it("rejects account credentials and signup parameters without starting authorization", async () => { + const { dependencies, handle } = workflow(); + for (const args of [{ password: "do-not-send" }, { email: "user@example.test" }, { signup: true }]) { + await expect(handle("start_lumin_authorization", args)).rejects.toMatchObject({ code: "LUMIN_WORKFLOW_INPUT_INVALID" }); + } + expect(dependencies.createOAuthSession).not.toHaveBeenCalled(); + expect(dependencies.openExternal).not.toHaveBeenCalled(); + expect(dependencies.fetchImpl).not.toHaveBeenCalled(); + }); + + it("preserves non-connection text without appending unrelated provider fields", () => { + const extra = { next_step: ACCESS_TOKEN }; + expect(formatLuminSigningToolText("prepare_lumin_request", extra)) + .toBe("Prepared the Lumin signing request locally. Nothing was sent."); + expect(formatLuminSigningToolText("send_lumin_request", { ...extra, signature_request_id: "request.1" })) + .toBe("Created Lumin signing request request.1."); + expect(formatLuminSigningToolText("check_lumin_status", { ...extra, provider_status: "APPROVED" })) + .toBe("Lumin signing status: APPROVED."); + expect(formatLuminSigningToolText("download_lumin_artifact", { ...extra, file_type: "agreement", pdf_path: "/synthetic/result.pdf" })) + .toBe("Downloaded the Lumin agreement PDF to /synthetic/result.pdf."); + }); + + it("closes an expired signup connection before advising a fresh browser attempt", async () => { + let nowMs = NOW_MS; + const { dependencies, handle } = workflow({ now: () => nowMs }); + const started = await handle("start_lumin_authorization", {}); + const session = await dependencies.createOAuthSession.mock.results[0].value; + nowMs += 300_000; + await expect(handle("finish_lumin_authorization", { authorization_session_id: started.authorization_session_id })) + .rejects.toMatchObject({ code: "LUMIN_AUTHORIZATION_SESSION_INVALID", message: expect.stringContaining("new session ID") }); + expect(session.close).toHaveBeenCalledOnce(); + expect(session.exchangeToken).not.toHaveBeenCalled(); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); + const fresh = await handle("start_lumin_authorization", {}); + expect(fresh.authorization_session_id).not.toBe(started.authorization_session_id); + expect(fresh.pdf_sent).toBe(false); + }); + + it("allows a new connection after cancellation without replaying the old session or leaking provider text", async () => { + const { dependencies, handle } = workflow(); + const started = await handle("start_lumin_authorization", {}); + const session = await dependencies.createOAuthSession.mock.results[0].value; + session.waitForCallback.mockRejectedValue(Object.assign(new Error(ACCESS_TOKEN), { code: "LUMIN_OAUTH_PROVIDER_ERROR" })); + await expect(handle("finish_lumin_authorization", { authorization_session_id: started.authorization_session_id })) + .rejects.toMatchObject({ code: "LUMIN_OAUTH_PROVIDER_ERROR", message: expect.stringContaining("leave it disconnected") }); + expect(session.close).toHaveBeenCalledOnce(); + expect(session.exchangeToken).not.toHaveBeenCalled(); + await expect(handle("finish_lumin_authorization", { authorization_session_id: started.authorization_session_id })) + .rejects.toMatchObject({ code: "LUMIN_AUTHORIZATION_SESSION_INVALID" }); + const fresh = await handle("start_lumin_authorization", {}); + const connected = await handle("finish_lumin_authorization", { authorization_session_id: fresh.authorization_session_id }); + expect(connected.status).toBe("connected"); + expect(JSON.stringify(connected)).not.toContain(ACCESS_TOKEN); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); + expect(dependencies.fetchImpl).not.toHaveBeenCalled(); + }); + + it("explains reconnecting an expired token without resending an existing request", async () => { + let nowMs = NOW_MS; + const { dependencies, handle } = workflow({ now: () => nowMs }); + await connect(handle); + nowMs += 3_600_000; + await expect(handle("check_lumin_status", { authority_sha256: "a".repeat(64), authorization_session_id: "authorization.session" })) + .rejects.toMatchObject({ code: "LUMIN_AUTHORIZATION_REQUIRED", message: expect.stringContaining("not send_lumin_request") }); + expect(dependencies.pollStatus).not.toHaveBeenCalled(); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); + }); + + it("closes the callback and gives safe recovery when a browser cannot be opened", async () => { + const { dependencies, handle } = workflow({ openExternal: vi.fn(async () => { throw new Error(ACCESS_TOKEN); }) }); + await expect(handle("start_lumin_authorization", {})).rejects.toMatchObject({ + code: "LUMIN_BROWSER_OPEN_FAILED", message: expect.stringContaining("Check your default browser"), + }); + const session = await dependencies.createOAuthSession.mock.results[0].value; + expect(session.close).toHaveBeenCalledOnce(); + expect(session.exchangeToken).not.toHaveBeenCalled(); + expect(dependencies.executeCreate).not.toHaveBeenCalled(); }); it("sanitizes authorization startup failures", async () => { diff --git a/test/mcp-contract.test.js b/test/mcp-contract.test.js index 2197f8d..1209a23 100644 --- a/test/mcp-contract.test.js +++ b/test/mcp-contract.test.js @@ -184,7 +184,11 @@ const EXAMPLE_PDF = path.join(REPO_ROOT, "example-fw9.pdf"); // wire-visible coverage-semantics change. No tool name, description, input // schema, or read-only annotation changes. The prior current-master digest was // 7f9fde99a3a88418a1f4011e4d47617da03991df03391bbeba3a0bff6bbf86ae. -const TOOL_CONTRACT_SHA256 = "fd4b388bf9f7e45a4cbffea05a15ecc99c739c1486bdfa2b7b91e7ec3bbc6216"; +// 2026-09-05: Lumin connection guidance distinguishes missing installation +// configuration from account signup; start/finish return a next_step string. +// Tool names, input schemas, send confirmation and transport remain unchanged. +// Previously fd4b388bf9f7e45a4cbffea05a15ecc99c739c1486bdfa2b7b91e7ec3bbc6216. +const TOOL_CONTRACT_SHA256 = "c3a5e232adbad9bc08060530fa800dfa11217df7231553f93d25561a3e5f5ad4"; const CLOSED_READ = Object.freeze({ readOnlyHint: true,