From 9ba2d64a99f85f826ba49bdf21712d7037d8e3e6 Mon Sep 17 00:00:00 2001 From: am-will Date: Fri, 16 Oct 2026 00:56:50 -0600 Subject: [PATCH] Harden OpenAI packaging and document remaining submission gates --- .claude-plugin/marketplace.json | 2 +- .github/workflows/check.yml | 2 + .gitignore | 1 + AGENTS.md | 4 + README.md | 6 ++ ci/check.py | 34 ++++++- ci/package_openai.py | 40 +++++--- ci/test_package_openai.py | 97 +++++++++++++++++++ docs/openai-submission.md | 92 ++++++++++++++++++ plugins/nutrient/.claude-plugin/plugin.json | 2 +- plugins/nutrient/.codex-plugin/plugin.json | 2 +- .../skills/nutrient-documents/SKILL.md | 13 +-- 12 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 ci/test_package_openai.py create mode 100644 docs/openai-submission.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index aa11d5a..f4fdab5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "nutrient", "source": "./plugins/nutrient", "description": "Parse, extract from and classify documents with Nutrient DWS: OCR for scans, Markdown with layout, structured fields with citations, and labels, as stored runs in your Nutrient account.", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "Nutrient", "url": "https://www.nutrient.io" }, "homepage": "https://www.nutrient.io/api/", "license": "MIT" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2acbbfa..ef92c1b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,3 +17,5 @@ jobs: run: python3 ci/check.py - name: Build the OpenAI package run: python3 ci/package_openai.py + - name: Check packaging boundaries and reproducibility + run: python3 -m unittest discover -s ci -p 'test_*.py' diff --git a/.gitignore b/.gitignore index 849ddff..2ead01d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 06b34b4..127779c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ OpenAI's directory takes a ZIP of `plugins/nutrient`, uploaded for each release. Never change `name` in `plugin.json`: it is the plugin's permanent identity. +For packaging changes, also run `python3 -m unittest discover -s ci -p 'test_*.py'`. The ZIP uses an explicit file list +in `ci/package_openai.py`; add new referenced resources there deliberately. Never add reviewer access or local app mappings. +See [the submission handoff](docs/openai-submission.md) for checks that still require the owner and a live ChatGPT run. + ## Refreshing the tool snapshot `ci/tools.json` and `ci/tools-chatgpt.json` are the catalogs Hosted MCP (`hosted-mcp/` in the private Nutrient monorepo) diff --git a/README.md b/README.md index a952ee2..2e439b2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ On Team and Enterprise plans, an Owner can roll the plugin out to the whole orga - [`ci/check.py`](ci/check.py): checks the plugin against [`ci/tools.json`](ci/tools.json), a snapshot of the service's tools; see [AGENTS.md](AGENTS.md) for changing the skill and refreshing the snapshot +## OpenAI submission + +The existing submission package is built with `python3 ci/package_openai.py`. See the +[submission handoff](docs/openai-submission.md) for validation, review cases and remaining owner steps. A passing local +build is not a completed directory review. + ## License [MIT](LICENSE) diff --git a/ci/check.py b/ci/check.py index 2f8dc9a..a684d6c 100644 --- a/ci/check.py +++ b/ci/check.py @@ -60,6 +60,19 @@ def collect(node, names): collect(item, names) +def credential_fields(node): + """Reject credential containers even when nested in an otherwise supported extension.""" + forbidden = {"clientsecret", "accesstoken", "refreshtoken", "apikey", "password", "testcredentials", "reviewerinstructions"} + if isinstance(node, dict): + for key, value in node.items(): + check(re.sub(r"[_-]", "", key).lower() not in forbidden, + f"credential field {key} stays outside the public package") + credential_fields(value) + elif isinstance(node, list): + for value in node: + credential_fields(value) + + marketplace = load(ROOT / ".claude-plugin" / "marketplace.json") manifest = load(PLUGIN / ".claude-plugin" / "plugin.json") entries = [entry for entry in marketplace.get("plugins", []) if entry.get("name") == manifest.get("name")] @@ -80,6 +93,7 @@ def collect(node, names): # OpenAI (ChatGPT and Codex): .codex-plugin/plugin.json shares .mcp.json and skills/ with Claude's manifest. codex = load(PLUGIN / ".codex-plugin" / "plugin.json") +credential_fields(codex) check(codex.get("name") == manifest.get("name") and codex.get("version") == manifest.get("version"), ".codex-plugin/plugin.json has the same name and version as .claude-plugin/plugin.json") check(codex.get("mcpServers") == "./.mcp.json" and codex.get("skills") == "./skills/", @@ -104,10 +118,12 @@ def collect(node, names): data = image.read_bytes() if image.is_relative_to(PLUGIN) and image.is_file() else b"" # A complete PNG: signature, then IHDR's width and height, ending with the IEND chunk. size = struct.unpack(">II", data[16:24]) if data[:8] == b"\x89PNG\r\n\x1a\n" and data.endswith(b"IEND\xaeB`\x82") else None - check(size is not None and size[0] == size[1] and size[0] >= 48, - f".codex-plugin/plugin.json {field} is a complete square PNG of at least 48 pixels inside the plugin") + check(size is not None and size[0] == size[1] and 48 <= size[0] <= 4096 and len(data) <= 5 * 1024 * 1024, + f".codex-plugin/plugin.json {field} is a complete square PNG, 48–4096 pixels and at most 5 MiB, inside the plugin") # OpenAI's review metadata: exactly five positive and three negative cases for the initial MCP review, no credentials. openai = codex.get("extensions", {}).get("com.openai", {}) +check(set(openai) <= {"review", "publication"}, + ".codex-plugin/plugin.json OpenAI extension has only reviewed fields (no local apps or hooks)") review = openai.get("review", {}) cases = review.get("test_cases", {}) positive, negative = cases.get("positive", []), cases.get("negative", []) @@ -130,9 +146,19 @@ def collect(node, names): check(len(openai_entries) == 1 and openai_entries[0].get("policy", {}).get("installation") in ("AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"), ".agents/plugins/marketplace.json gives the plugin a valid installation policy") -servers = load(PLUGIN / ".mcp.json").get("mcpServers", {}) -check(list(servers) == ["nutrient"] and servers["nutrient"].get("type") == "http" and servers["nutrient"].get("url") == SERVICE, +mcp = load(PLUGIN / ".mcp.json") +check(mcp == {"mcpServers": {"nutrient": {"type": "http", "url": SERVICE}}}, f".mcp.json declares one server, nutrient, at {SERVICE} over HTTP (OpenAI connects one server per plugin)") +# The production connection uses OAuth discovery. No fixed headers, embedded credentials, local app mappings, +# or registration overrides belong in this package. Changes to authentication need an explicit review here. +check(set(codex) <= {"name", "version", "description", "author", "homepage", "repository", "license", "keywords", + "skills", "mcpServers", "interface", "extensions"}, + ".codex-plugin/plugin.json has only the reviewed package fields (no local apps or hooks)") +for catalog in ("tools.json", "tools-chatgpt.json"): + for tool in load(ROOT / "ci" / catalog): + annotations = tool.get("annotations", {}) + check(all(type(annotations.get(hint)) is bool for hint in ("readOnlyHint", "destructiveHint", "openWorldHint")), + f"{catalog} {tool['name']} has explicit boolean tool annotations") skills = sorted((PLUGIN / "skills").glob("*/SKILL.md")) check(len(skills) > 0, "the plugin has at least one skill") diff --git a/ci/package_openai.py b/ci/package_openai.py index c37f91a..0d7f97e 100644 --- a/ci/package_openai.py +++ b/ci/package_openai.py @@ -15,22 +15,34 @@ ROOT = Path(__file__).resolve().parent.parent PLUGIN = ROOT / "plugins" / "nutrient" -# Claude's manifest, and files an editor or OS leaves behind. -EXCLUDED_DIRECTORIES = {".claude-plugin", "__pycache__"} -EXCLUDED_FILES = {".DS_Store", "Thumbs.db"} +# Keep review notes, local app mappings, credentials and unexpected files out of the public ZIP. +# Add new resources here deliberately when the plugin starts referencing them. +PACKAGE_FILES = ( + ".codex-plugin/plugin.json", + ".mcp.json", + "LICENSE", + "README.md", + "assets/logo.png", + "skills/nutrient-documents/SKILL.md", +) + +for path in (PLUGIN, *PLUGIN.rglob("*")): + if path.is_symlink(): + sys.exit(f"FAIL symbolic links are not allowed in the plugin: {path.relative_to(ROOT)}") subprocess.run([sys.executable, str(ROOT / "ci" / "check.py")], check=True) manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text()) -files = sorted( - path for path in PLUGIN.rglob("*") - if path.is_file() and path.name not in EXCLUDED_FILES - and not EXCLUDED_DIRECTORIES & set(path.relative_to(PLUGIN).parts[:-1]) -) -names = [path.relative_to(PLUGIN).as_posix() for path in files] -for required in (".codex-plugin/plugin.json", ".mcp.json"): - if required not in names: - sys.exit(f"FAIL the package has no {required}") +names = sorted(PACKAGE_FILES) +files = [PLUGIN / name for name in names] +for path in files: + if not path.is_file(): + sys.exit(f"FAIL the package has no {path.relative_to(PLUGIN)}") +resources = [manifest["interface"][field] for field in ("logo", "composerIcon")] +resources.extend(path.relative_to(PLUGIN).as_posix() for path in (PLUGIN / "skills").rglob("*") if path.is_file()) +for resource in resources: + if resource.removeprefix("./") not in names: + sys.exit(f"FAIL add referenced resource {resource} to PACKAGE_FILES") output = ROOT / "dist" / f"nutrient-openai-{manifest['version']}.zip" output.parent.mkdir(exist_ok=True) @@ -41,6 +53,10 @@ entry.compress_type = zipfile.ZIP_DEFLATED archive.writestr(entry, path.read_bytes()) +if output.stat().st_size > 100 * 1024 * 1024: + output.unlink() + sys.exit("FAIL the OpenAI package exceeds 100 MiB") + digest = hashlib.sha256(output.read_bytes()).hexdigest() print("\n".join(" " + name for name in names)) print(f"PASS {output.relative_to(ROOT)} ({output.stat().st_size} bytes, sha256 {digest})") diff --git a/ci/test_package_openai.py b/ci/test_package_openai.py new file mode 100644 index 0000000..2e13f73 --- /dev/null +++ b/ci/test_package_openai.py @@ -0,0 +1,97 @@ +"""Exercise the public ZIP boundary in disposable copies, without network access.""" + +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXPECTED = { + ".codex-plugin/plugin.json", ".mcp.json", "LICENSE", "README.md", + "assets/logo.png", "skills/nutrient-documents/SKILL.md", +} + + +class PackageTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for name in ("ci", "plugins", ".claude-plugin", ".agents"): + shutil.copytree(ROOT / name, self.root / name, ignore=shutil.ignore_patterns("__pycache__")) + for name in ("README.md", "LICENSE"): + shutil.copy2(ROOT / name, self.root / name) + self.plugin = self.root / "plugins" / "nutrient" + + def build(self, success=True): + result = subprocess.run([sys.executable, str(self.root / "ci/package_openai.py")], + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + if not success: + self.assertFalse(list((self.root / "dist").glob("*.zip"))) + return result.stdout + result.stderr + + def edit_json(self, relative, edit): + path = self.plugin / relative + value = json.loads(path.read_text()) + edit(value) + path.write_text(json.dumps(value)) + + def test_reproducible_package_and_private_files_excluded(self): + for name in (".env", ".app.json", "reviewer-credentials.txt"): + (self.plugin / name).write_text("PACKAGING-CANARY-PRIVATE") + self.build() + archive_path, = (self.root / "dist").glob("*.zip") + first = archive_path.read_bytes() + self.build() + self.assertEqual(first, archive_path.read_bytes()) + with zipfile.ZipFile(archive_path) as archive: + self.assertEqual(set(archive.namelist()), EXPECTED) + self.assertIsNone(archive.testzip()) + for name in archive.namelist(): + self.assertNotIn(b"PACKAGING-CANARY-PRIVATE", archive.read(name)) + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + self.assertEqual(manifest["name"], "nutrient") + self.assertEqual(manifest["mcpServers"], "./.mcp.json") + self.assertLess(len(first), 100 * 1024 * 1024) + + def test_fixed_authentication_header_rejected(self): + self.edit_json(".mcp.json", lambda m: m["mcpServers"]["nutrient"].update( + headers={"Authorization": "Bearer PACKAGING-CANARY-PRIVATE"})) + self.assertIn("FAIL .mcp.json", self.build(success=False)) + + def test_nested_client_secret_rejected(self): + self.edit_json(".codex-plugin/plugin.json", lambda m: m["extensions"]["com.openai"].update( + auth={"clientSecret": "PACKAGING-CANARY-PRIVATE"})) + self.assertIn("credential field clientSecret", self.build(success=False)) + + def test_local_app_mapping_rejected(self): + self.edit_json(".codex-plugin/plugin.json", lambda m: m.update(apps="./.app.json")) + self.assertIn("no local apps or hooks", self.build(success=False)) + + def test_nested_local_app_mapping_rejected(self): + self.edit_json(".codex-plugin/plugin.json", lambda m: m["extensions"]["com.openai"].update(apps="./.app.json")) + self.assertIn("no local apps or hooks", self.build(success=False)) + + def test_symlink_to_external_file_rejected(self): + outside = self.root / "private.txt" + outside.write_text("PACKAGING-CANARY-PRIVATE") + logo = self.plugin / "assets/logo.png" + logo.unlink() + logo.symlink_to(outside) + self.assertIn("symbolic links", self.build(success=False)) + + def test_non_boolean_annotation_rejected(self): + path = self.root / "ci/tools-chatgpt.json" + tools = json.loads(path.read_text()) + tools[0]["annotations"]["readOnlyHint"] = "false" + path.write_text(json.dumps(tools)) + self.assertIn("explicit boolean tool annotations", self.build(success=False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/openai-submission.md b/docs/openai-submission.md new file mode 100644 index 0000000..07632b1 --- /dev/null +++ b/docs/openai-submission.md @@ -0,0 +1,92 @@ +# OpenAI submission handoff + +This extends Oli's existing package. Check its current draft in [OpenAI Plugins](https://platform.openai.com/plugins) +before uploading; submission status has not been inspected. No application, deployment or legal acceptance was performed. + +## Build and verify + +```sh +python3 ci/check.py +claude plugin validate ./plugins/nutrient +python3 -m unittest discover -s ci -p 'test_*.py' -v +python3 ci/package_openai.py +``` + +Candidate: `dist/nutrient-openai-1.0.1.zip`; the builder prints its SHA-256. Only six reviewed files are packaged at one +root. Private files and local app mappings are excluded, symlinks rejected, and referenced resources checked. Tests +cover credential fields/headers, app mappings, annotation types and reproducibility. This is not a general secret detector. +The ZIP stays out of Git. Reproducibility assumes the same Python/zlib environment. + +OpenAI still supports this Codex layout (`.codex-plugin/plugin.json`, `.mcp.json`). The current portable schemas were +fetched for comparison; they do not validate this legacy format. Root `plugin.json`/`mcp.json` are recommended for new +packages, but migration is unnecessary here. The portable MCP schema currently rejects server extensions that OpenAI's +OAuth guide documents. Resolve that discrepancy with OpenAI before migrating; don't silently remove authentication. + +The existing connection uses OAuth discovery, with no embedded credentials, fixed headers or authentication overrides. +The public portal supports OAuth, mixed and no-auth connections, not an API-key setup flow. Do not switch this protected +service to no-auth. + +## Checked on 16 October 2026 + +| Evidence | Result / limit | +| --- | --- | +| Repository | Its instructions identify this package; Oli's [merged ChatGPT fix](https://github.com/PSPDFKit/PSPDFKit/pull/60270) references it. [Earlier directory work](https://github.com/PSPDFKit/PSPDFKit/pull/60047) is merged. No second package was created. | +| Local checks | Listing limits, required URLs, existing logo, five positive/three negative cases and both catalogs pass. Claude validation and an isolated Codex marketplace install pass. These are not portal or conversation tests. | +| Public URLs | Setup, product, privacy, terms, support and all three PDF fixtures returned HTTP 200. Reachability does not establish legal coverage. | +| Transport | Unauthenticated POST initialize returned 401 with a Bearer challenge naming `https://mcp.nutrient.io/.well-known/oauth-protected-resource/mcp`. GET `/mcp` returned 405, allowing POST. | +| Discovery | Resource metadata names the exact endpoint and `https://api.nutrient.io`. Authorization metadata advertises DCR, PKCE S256 and refresh. No registration or sign-in was attempted. | +| Catalogs | Base `f624c7c83c116204fe25b2078dbf27c4790013fc` records release-build-14 snapshots: 13 Apps-client tools, nine ChatGPT tools, all with explicit boolean hints. No authenticated production scan was run. | +| Host routing | Hosted README/`ToolFilters.cs` confirm ChatGPT hides local-file tools. The skill now avoids calling them, including its upload fallback. Live behavior remains untested. | + +## Submission blockers + +| Owner | Next step | +| --- | --- | +| Oli / OpenAI org owner | Confirm existing draft, global-residency project, Nutrient business verification and Apps Management Write. Complete the portal's domain challenge without replacing another plugin's token. Statuses unknown. | +| DWS account owner | Supply an approved sample-data reviewer account with credits, entitlements and Viewer region ready. It must work without MFA, codes, magic links or private-network hurdles. Do not weaken an existing account. Enter credentials only in the secure portal, never Git/ZIP. | +| Demo owner | Record OAuth, attached PDF, finished result, page evidence and usage in ChatGPT. Add the accessible `review.demo_recording_url` once real; it is absent. Keep secrets out of the recording. | +| Product / privacy / security | Resolve prohibited PHI, PCI data, government identifiers and authentication secrets. Establish reviewed input/processing safeguards or narrow the service surface; a skill instruction alone is insufficient. Verify retention, subprocessors, logging and Viewer copies against public disclosures. | +| Service owner | Test OAuth registration, PKCE, exact redirects, issuer/audience/expiry/scopes, refresh, revocation and wrong-account/tenant boundaries. Test the actual ChatGPT listing, panel domain/CSP, CORS and downloads with the reviewer account. | +| Oli | Scan skills/tools, resolve findings, check country availability, commerce and attestations, then submit only after owner review. None of those portal actions was performed here. | + +The public [setup page](https://www.nutrient.io/mcp/) links to a legacy local-server guide promoting SSN, PHI and PCI +examples. Product/website owners should review those claims for ChatGPT. Hosted fixes belong in +`PSPDFKit/PSPDFKit/hosted-mcp` with its HTTP tests; website changes and production promotion remain separate owner work. + +## Live review cases + +Keep the existing manifest's five positive and three negative cases. Expected behaviors are targets, not audit results. +Record tools, arguments, outcomes, confirmations and redacted evidence with package SHA and production version. + +| Case | Verify | +| --- | --- | +| Invoice URL / `extract_fields` | Fixture values, citations and page highlights agree. | +| Attached menu / `parse_document` | Real `chat_file`, no second upload or invented URL; Markdown and panel load. | +| Contract / `classify_document` | Requested label and confidence come from Nutrient. | +| Invoice / `generate_schema` | Schema only; no extraction or stored run. | +| Credits / `get_account_usage` | Reviewer account's current usage only. | +| Coffee names; delete documents; write agreement | No Nutrient call or implied unsupported action. | + +Also test missing attachment metadata (ask for an HTTPS link, not an unlisted upload tool), explicit refusal to send data +to Nutrient, low credits, pending-run continuation without resubmission and adversarial document text. Review restricted-data +requests with synthetic placeholders, never actual restricted data. Results remain pending. + +## Annotation notes for review + +Parse/extract/classify queue work and retain artifacts: read-only is false. Arbitrary document URLs make open-world true. +Their additive/non-destructive claim needs confirmation against all modes and billing effects. Schema generation is +marked read-only because source documents no retained run or charge, but remains open-world. Account/run/result/job reads +and Studio links are bounded-account reads. Review's Viewer copy is a separate persisted artifact: check app-only writes too. + +ChatGPT hides Processor/signing/redaction tools but the service leaves them callable for older panels. Confirm the scan +reviews that surface; hiding a tool does not establish compliance. Do not hand-edit snapshots to clear a finding. +Guidelines say annotation justifications are no longer required; the review page still discusses them. Keep factual +explanations ready and check the actual portal. + +Sources: [submission / field limits](https://developers.openai.com/plugins/deploy/submission), +[review requirements](https://developers.openai.com/plugins/deploy/app-review), +[package guide](https://developers.openai.com/plugins/build/plugins), +[MCP server guide](https://developers.openai.com/plugins/build/mcp-server), +[guidelines](https://developers.openai.com/plugins/plugin-guidelines), +[portable manifest schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json), +[portable MCP schema](https://agent-plugins.org/schemas/1.0.0/mcp.schema.json). Recheck before upload. diff --git a/plugins/nutrient/.claude-plugin/plugin.json b/plugins/nutrient/.claude-plugin/plugin.json index c770367..fa6d118 100644 --- a/plugins/nutrient/.claude-plugin/plugin.json +++ b/plugins/nutrient/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nutrient", "displayName": "Nutrient", - "version": "1.0.0", + "version": "1.0.1", "description": "Parse, extract from and classify documents with Nutrient DWS: OCR for scans, Markdown with layout, structured fields with citations, and labels, as stored runs in your Nutrient account.", "author": { "name": "Nutrient", "url": "https://www.nutrient.io" }, "homepage": "https://www.nutrient.io/api/", diff --git a/plugins/nutrient/.codex-plugin/plugin.json b/plugins/nutrient/.codex-plugin/plugin.json index fd62fd7..2b81140 100644 --- a/plugins/nutrient/.codex-plugin/plugin.json +++ b/plugins/nutrient/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "nutrient", - "version": "1.0.0", + "version": "1.0.1", "description": "Parse, extract from and classify documents with Nutrient DWS: OCR for scans, Markdown with layout, structured fields with citations, and labels, as stored runs in your Nutrient account.", "author": { "name": "Nutrient", diff --git a/plugins/nutrient/skills/nutrient-documents/SKILL.md b/plugins/nutrient/skills/nutrient-documents/SKILL.md index 48db310..09cc98b 100644 --- a/plugins/nutrient/skills/nutrient-documents/SKILL.md +++ b/plugins/nutrient/skills/nutrient-documents/SKILL.md @@ -18,7 +18,8 @@ mention Nutrient, say that the file goes to their Nutrient account as you send i | Classify documents or sort them into categories | `classify_document`, with at least two `instructions.labels` | For a file as the result, such as a searchable PDF, a conversion, a redaction or a signature, use `process_document`, -`redact_document` or `sign_document`. +`redact_document` or `sign_document` only when the host lists that tool. If it doesn't, explain that this operation +isn't available through Nutrient in this host; don't attempt an unlisted tool. Each run uses the user's Nutrient DWS credits. For a summary of a document or a free-form question about it, read it yourself unless the user asks for one of the operations above; parse a scan or image you can't read. Before running more @@ -29,16 +30,16 @@ than three files, tell the user how many runs you will start. 1. If the processing tool takes `chat_file` and the user attached the file to the chat, pass it there: the app fills in its `download_url`, and Nutrient fetches the file from it. Never type those values yourself. If the app didn't fill it in, continue with the next step. -2. Otherwise, for a file attached to the chat or on disk, call `upload_documents` with its `filename`, `content_type` and +2. Otherwise, if the host lists `upload_documents`, for a file attached to the chat or on disk, call it with its `filename`, `content_type` and `path`, run the command it returns exactly as returned, and pass the printed `asset_id` to the processing tool. The command expires within five minutes; if it has, call `upload_documents` again. Reuse an `asset_id` for further operations on the same file; it expires after 24 hours. -3. If that command can't run where the file is, or fails with HTTP 403 "Host not in allowlist", don't retry it. When the +3. If the host doesn't list `upload_documents`, that command can't run where the file is, or it fails with HTTP 403 + "Host not in allowlist", don't retry it. When the processing tool offers `attached_file`, call it once per file with the file's `filename` and `content_type` instead of `asset_id`: the Nutrient panel asks the user for the file and runs the call. Tell the user to add the file in the - panel, and don't call again. Otherwise ask the user to allow `mcp.nutrient.io` in the app's network settings (in - Claude: Settings, Capabilities, Additional allowed domains), or to share the document as an HTTPS link, which - `document.url` takes. + panel, and don't call again. Otherwise ask the user to share the document as an HTTPS link, which `document.url` + takes. In Claude, they can also allow `mcp.nutrient.io` under Settings, Capabilities, Additional allowed domains. 4. Never paste a document as base64 to get around an upload problem. ## Read the results