diff --git a/README.md b/README.md
index 02ad872..a749746 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,25 @@

-
+
[](https://www.npmjs.com/package/@nutrient-sdk/dws-mcp-server)
**Give AI agents the power to process, sign, and transform documents.**
+## Description
+
A Model Context Protocol (MCP) server that connects AI assistants to the [Nutrient Document Web Service (DWS) Processor API](https://www.nutrient.io/api) — enabling document creation, editing, conversion, digital signing, OCR, redaction, and more through natural language.
+## Features
+
+- Local stdio MCP server for Claude Desktop and other MCP-compatible clients
+- Browser-based OAuth on the first request that uses the Nutrient API, with optional API-key fallback for CI and headless environments
+- Document conversion, OCR, extraction, redaction, watermarking, annotation flattening, and digital signing
+- Sandbox-aware local file handling with explicit output paths
+- Read-only account lookup for DWS credits and usage
+
## What You Can Do
Once configured, you (or your AI agent) can process documents through natural language:
@@ -31,12 +41,16 @@ Once configured, you (or your AI agent) can process documents through natural la
**You:** _"OCR this scanned document in German and extract the text"_
**AI:** _"I've processed the scan with German OCR. Here's the extracted text..."_
-## Quick Start
+## Installation
-### 1. Get a Nutrient API Key
+Install it from Claude Desktop Settings -> Extensions if you are using Claude Desktop. If you are developing locally, use the manual setup below.
+
+### 1. Create a Nutrient Account
Sign up for free at [nutrient.io/api](https://dashboard.nutrient.io/sign_up/).
+For local desktop use, the recommended path is to omit `NUTRIENT_DWS_API_KEY` and complete the browser sign-in flow on the first request that uses the Nutrient API. For CI, headless environments, or scripted setups, create an API key in the dashboard and set `NUTRIENT_DWS_API_KEY`.
+
### 2. Configure Your AI Client
Choose your platform and add the configuration:
@@ -56,12 +70,13 @@ Open Settings → Developer → Edit Config, then add:
"command": "npx",
"args": ["-y", "@nutrient-sdk/dws-mcp-server"],
"env": {
- "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE",
"SANDBOX_PATH": "/your/sandbox/directory",
// "C:\\your\\sandbox\\directory" for Windows
- },
- },
- },
+ // Optional for CI or headless usage:
+ // "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE"
+ }
+ }
+ }
}
```
@@ -79,12 +94,13 @@ Create `.cursor/mcp.json` in your project root:
"command": "npx",
"args": ["-y", "@nutrient-sdk/dws-mcp-server"],
"env": {
- "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE",
"SANDBOX_PATH": "/your/project/documents",
// "C:\\your\\project\\documents" for Windows
- },
- },
- },
+ // Optional for CI or headless usage:
+ // "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE"
+ }
+ }
+ }
}
```
@@ -102,12 +118,13 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
"command": "npx",
"args": ["-y", "@nutrient-sdk/dws-mcp-server"],
"env": {
- "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE",
"SANDBOX_PATH": "/your/sandbox/directory",
// "C:\\your\\sandbox\\directory" for Windows
- },
- },
- },
+ // Optional for CI or headless usage:
+ // "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE"
+ }
+ }
+ }
}
```
@@ -116,19 +133,19 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
VS Code (GitHub Copilot)
-Add to `.vscode/settings.json` in your project:
+Create `.vscode/mcp.json` in your project, or add the same server definition to your user `mcp.json` profile:
-```json
+```jsonc
{
- "mcp": {
- "servers": {
- "nutrient-dws": {
- "command": "npx",
- "args": ["-y", "@nutrient-sdk/dws-mcp-server"],
- "env": {
- "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE",
- "SANDBOX_PATH": "${workspaceFolder}"
- }
+ "servers": {
+ "nutrient-dws": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@nutrient-sdk/dws-mcp-server"],
+ "env": {
+ "SANDBOX_PATH": "${workspaceFolder}",
+ // Optional for CI or headless usage:
+ // "NUTRIENT_DWS_API_KEY": "YOUR_API_KEY_HERE"
}
}
}
@@ -143,6 +160,9 @@ Add to `.vscode/settings.json` in your project:
Any MCP-compatible client can connect using stdio transport:
```bash
+SANDBOX_PATH=/your/path npx @nutrient-sdk/dws-mcp-server
+
+# Optional for CI or headless usage:
NUTRIENT_DWS_API_KEY=your_key SANDBOX_PATH=/your/path npx @nutrient-sdk/dws-mcp-server
```
@@ -154,16 +174,18 @@ Restart the application to pick up the new MCP server configuration.
### 4. Start Processing Documents
-Drop documents into your sandbox directory and start giving instructions!
+Place documents in your sandbox directory and use explicit file names or paths in prompts. Explicit paths are safer and more reliable than vague file-browsing requests.
## Available Tools
-| Tool | Description |
-| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **document_processor** | All-in-one document processing: merge PDFs, convert formats, apply OCR, watermark, rotate, redact, flatten annotations, extract text/tables/key-value pairs, and more |
-| **document_signer** | Digitally sign PDFs with PAdES-compliant CMS or CAdES signatures, with customizable visible/invisible signature appearances |
-| **sandbox_file_tree** | Browse files in the sandbox directory (when sandbox mode is enabled) |
-| **directory_tree** | Browse directory contents (when sandbox mode is disabled) |
+| Tool | Description |
+| ---- | ----------- |
+| `document_processor` | Document processing for conversions, OCR, extraction, watermarking, rotation, annotation flattening, and redaction workflows |
+| `document_signer` | PDF signing with CMS / PKCS#7 and CAdES signatures plus visible or invisible appearance options |
+| `ai_redactor` | AI redaction for detecting and permanently removing sensitive content such as names, addresses, SSNs, emails, and custom criteria |
+| `check_credits` | Read-only account lookup for current DWS credits and usage. No document content is uploaded |
+| `sandbox_file_tree` | Read-only view of files inside the configured sandbox directory |
+| `directory_tree` | Read-only view of local files when sandbox mode is disabled. Sandbox mode is strongly recommended |
### Document Processor Capabilities
@@ -179,6 +201,28 @@ Drop documents into your sandbox directory and start giving instructions!
| Annotations | Import XFDF annotations, flatten annotations |
| Digital Signing | PAdES-compliant CMS and CAdES digital signatures (via document_signer tool) |
+## Usage Examples
+
+These examples assume your files live inside the configured sandbox and that you use explicit paths.
+
+### Example 1: HTML -> PDF -> signing
+
+**User prompt:** `Convert /path/to/sandbox/invoice.html to PDF and save it as /path/to/sandbox/invoice.pdf. Then digitally sign /path/to/sandbox/invoice.pdf with a visible signature and save it as /path/to/sandbox/invoice-signed.pdf.`
+
+**What happens:** The server uploads the HTML file to Nutrient, saves the generated PDF in the sandbox, then signs that PDF and writes the signed result back to the requested output path.
+
+### Example 2: OCR extraction
+
+**User prompt:** `Run OCR on /path/to/sandbox/scanned-contract.pdf, return the extracted text, and save the OCR'd file as /path/to/sandbox/scanned-contract-ocr.pdf.`
+
+**What happens:** The server sends the scanned PDF to Nutrient for OCR, returns the extracted text in Claude, and writes the OCR-processed file back to the sandbox for later use.
+
+### Example 3: Check credits -> process -> inspect output
+
+**User prompt:** `Check my Nutrient credits, convert /path/to/sandbox/report.docx to PDF, save it as /path/to/sandbox/report.pdf, and then tell me where the output file was written.`
+
+**What happens:** The server first performs a read-only account lookup, then converts the DOCX file to PDF, saves the result in the sandbox, and tells the user exactly where the output file was written.
+
## Use with AI Agent Frameworks
This MCP server works with any platform that supports the Model Context Protocol:
@@ -215,6 +259,8 @@ export SANDBOX_PATH=/path/to/sandbox/directory
npx @nutrient-sdk/dws-mcp-server
```
+Supported CLI flags are `--sandbox ` and `-s `. Unrecognized flags cause a startup error.
+
When sandbox mode is enabled:
- Relative paths resolve relative to the sandbox directory
@@ -225,7 +271,7 @@ When sandbox mode is enabled:
### Output Location
-Processed files are saved to a location determined by the AI. To guide output placement, use natural language (e.g., "save the result to `output/result.pdf`") or create an `output` directory in your sandbox.
+Processed files are saved to a location determined by the AI. To guide output placement, use explicit output paths such as `save the result to /path/to/sandbox/output/result.pdf` or create an `output` directory in your sandbox.
### Authentication
@@ -234,27 +280,54 @@ The server authenticates to the Nutrient DWS API (`https://api.nutrient.io`) usi
| Method | When | Config |
|--------|------|--------|
| **API key** | `NUTRIENT_DWS_API_KEY` is set | Static key passed as Bearer token to DWS API |
-| **OAuth browser flow** | No API key set | Opens browser for Nutrient OAuth consent, caches token locally |
+| **OAuth browser flow** | No API key set | Opens browser for Nutrient OAuth consent on the first request that uses the Nutrient API, caches token locally |
-When no API key is configured, the server opens a browser-based OAuth flow on the first tool call (similar to `gh auth login`). Tokens are cached at `$XDG_CONFIG_HOME/nutrient/credentials.json` or `~/.config/nutrient/credentials.json` and refreshed automatically.
+When no API key is configured, the server stays connected and opens a browser-based OAuth flow on the first request that uses the Nutrient API (similar to `gh auth login`). Tokens are cached at `$XDG_CONFIG_HOME/nutrient/credentials.json` or `~/.config/nutrient/credentials.json` and refreshed automatically.
### Environment Variables
| Variable | Required | Description |
| ---------------------- | ----------- | -------------------------------------------------------------------------------------------- |
-| `NUTRIENT_DWS_API_KEY` | No* | Nutrient DWS API key ([get one free](https://dashboard.nutrient.io/sign_up/)) |
-| `SANDBOX_PATH` | Recommended | Directory to restrict file operations to |
-| `CLIENT_ID` | No | OAuth client ID. Skips DCR and enables token refresh when set |
-| `DWS_API_BASE_URL` | No | DWS API base URL (default: `https://api.nutrient.io`) |
-| `LOG_LEVEL` | No | Winston logger level (`info` default). Logs are written to `MCP_LOG_FILE` in stdio mode |
-| `MCP_LOG_FILE` | No | Override log file path (default: system temp directory) |
+| `NUTRIENT_DWS_API_KEY` | No* | Nutrient DWS API key ([get one free](https://dashboard.nutrient.io/sign_up/)) |
+| `SANDBOX_PATH` | Recommended | Directory to restrict file operations to |
+| `AUTH_SERVER_URL` | No | OAuth server base URL (default: `https://api.nutrient.io`) |
+| `CLIENT_ID` | No | OAuth client ID. Skips DCR and enables refresh token reuse when set |
+| `DWS_API_BASE_URL` | No | DWS API base URL (default: `https://api.nutrient.io`) |
+| `LOG_LEVEL` | No | Winston logger level (`info` default). Logs are written to `MCP_LOG_FILE` in stdio mode |
+| `MCP_LOG_FILE` | No | Override log file path (default: system temp directory) |
\* If omitted, the server uses an OAuth browser flow to authenticate with the Nutrient API.
+## Data Handling
+
+### What Stays Local
+
+- The MCP server process, sandbox enforcement, and file path resolution run on the local machine.
+- `sandbox_file_tree` and `directory_tree` inspect local files only. They do not upload document contents to Nutrient.
+- API keys and OAuth credentials are stored locally on the machine running the MCP server.
+
+### What Gets Sent to Nutrient
+
+- `document_processor`, `document_signer`, and `ai_redactor` upload the document files and processing instructions to the Nutrient DWS API so the requested operation can run.
+- `check_credits` sends an authenticated account lookup but does not upload document files.
+- Processed results are written back to the local output path you request.
+
### Security Note: Token Storage
When using the OAuth browser flow, access tokens and refresh tokens are cached in plaintext at `$XDG_CONFIG_HOME/nutrient/credentials.json` or `~/.config/nutrient/credentials.json` (permissions `0600`). This file contains credentials equivalent to your API key. Do not commit it to version control or include it in shared backups.
+## Privacy Policy
+
+This extension reads files from the local sandbox, sends document contents and processing instructions to Nutrient when you invoke document tools, and stores API keys or OAuth credentials locally on the machine running the MCP server.
+
+Nutrient's privacy policy is available at [nutrient.io/legal/privacy](https://www.nutrient.io/legal/privacy/).
+
+## Support
+
+For product or account support, contact Nutrient at [nutrient.io/company/contact](https://www.nutrient.io/company/contact/).
+
+For bugs or feature requests specific to this MCP package, use [GitHub issues](https://github.com/PSPDFKit/nutrient-dws-mcp-server/issues).
+
## Troubleshooting
### Reset authentication to a clean state
@@ -303,7 +376,7 @@ The server will automatically register a new client and open the browser for con
- Check that `SANDBOX_PATH` points to an existing directory
- Ensure your documents are inside the sandbox directory
-- Use the `sandbox_file_tree` tool to verify visible files
+- Ask the assistant to inspect the configured sandbox, or inspect the sandbox directory directly
## Contributing
diff --git a/benchmarks/core-runtime.mjs b/benchmarks/core-runtime.mjs
index 678175d..5ad6258 100644
--- a/benchmarks/core-runtime.mjs
+++ b/benchmarks/core-runtime.mjs
@@ -136,7 +136,7 @@ async function runBenchmark() {
try {
const totalMs = await runBenchmark()
- console.log(`METRIC total_ms=${totalMs}`)
+ globalThis.console.log(`METRIC total_ms=${totalMs}`)
} finally {
await setSandboxDirectory(null)
await fs.rm(fixtureRoot, { recursive: true, force: true })
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..bb454db
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,80 @@
+{
+ "manifest_version": "0.3",
+ "name": "nutrient-dws-mcp-server",
+ "display_name": "Nutrient DWS",
+ "version": "0.0.5",
+ "description": "Process, sign, redact, and transform documents from Claude Desktop using Nutrient.",
+ "long_description": "A local Claude Desktop extension for document processing with Nutrient. It runs as a stdio MCP server, reads files from a user-selected sandbox directory, opens a browser for OAuth on the first request that uses the Nutrient API, and writes processed results back to local output paths.",
+ "author": {
+ "name": "Nutrient",
+ "url": "https://www.nutrient.io/"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/PSPDFKit/nutrient-dws-mcp-server.git"
+ },
+ "homepage": "https://www.nutrient.io/mcp-server-pdf-automation-llm/",
+ "documentation": "https://www.nutrient.io/guides/dws-processor/getting-started/mcp-server/",
+ "support": "https://github.com/PSPDFKit/nutrient-dws-mcp-server/issues",
+ "keywords": [
+ "mcp",
+ "pdf",
+ "document-processing",
+ "ocr",
+ "redaction",
+ "digital-signature"
+ ],
+ "license": "MIT",
+ "privacy_policies": [
+ "https://www.nutrient.io/legal/privacy/"
+ ],
+ "server": {
+ "type": "node",
+ "entry_point": "dist/index.js",
+ "mcp_config": {
+ "command": "node",
+ "args": [
+ "${__dirname}/dist/index.js",
+ "--sandbox",
+ "${user_config.sandbox_path}"
+ ],
+ "env": {}
+ }
+ },
+ "tools": [
+ {
+ "name": "document_processor",
+ "description": "Convert, OCR, extract, watermark, rotate, flatten annotations, and process documents."
+ },
+ {
+ "name": "document_signer",
+ "description": "Digitally sign PDF files with CMS or CAdES signatures."
+ },
+ {
+ "name": "ai_redactor",
+ "description": "Detect and permanently redact sensitive content such as names, addresses, SSNs, and emails."
+ },
+ {
+ "name": "check_credits",
+ "description": "Check the current Nutrient DWS credit balance and usage."
+ },
+ {
+ "name": "sandbox_file_tree",
+ "description": "Browse files available in the configured sandbox directory."
+ }
+ ],
+ "compatibility": {
+ "claude_desktop": ">=0.10.0",
+ "runtimes": {
+ "node": ">=18.0.0"
+ }
+ },
+ "user_config": {
+ "sandbox_path": {
+ "type": "directory",
+ "title": "Sandbox Directory",
+ "description": "Directory the extension can read from and write to for document processing.",
+ "required": true
+ }
+ }
+}
diff --git a/package.json b/package.json
index 062eb04..c63c80c 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,9 @@
"build": "tsc && shx chmod +x dist/index.js",
"format": "prettier --write .",
"lint": "eslint .",
+ "manifest:sync-version": "node scripts/sync-manifest-version.mjs",
+ "mcpb:validate": "pnpm run manifest:sync-version && npx -y @anthropic-ai/mcpb validate manifest.json",
+ "mcpb:pack": "pnpm run manifest:sync-version && pnpm run build && node scripts/build-mcpb.mjs",
"pretest": "tsc --project tsconfig.test.json --noEmit",
"test": "vitest run",
"test:ci": "vitest run --exclude tests/build-api-examples.test.ts --exclude tests/signing-api-examples.test.ts",
diff --git a/scripts/build-mcpb.mjs b/scripts/build-mcpb.mjs
new file mode 100644
index 0000000..745c4bc
--- /dev/null
+++ b/scripts/build-mcpb.mjs
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+
+import { spawn } from 'node:child_process'
+import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const rootDir = path.resolve(__dirname, '..')
+const outputPath = path.resolve(process.argv[2] ?? path.join(rootDir, 'dist', 'nutrient-dws.mcpb'))
+const cleanEnv = { ...process.env }
+
+// pnpm injects npm_config_* environment variables that make npm print warnings
+// and can influence staging installs. Strip the known noisy ones for a clean,
+// reproducible npm install in the temporary bundle directory.
+for (const key of [
+ 'npm_config_supported_architectures',
+ 'npm_config_npm_globalconfig',
+ 'npm_config_verify_deps_before_run',
+ 'npm_config__jsr_registry',
+]) {
+ delete cleanEnv[key]
+}
+
+function run(command, args, cwd) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd,
+ env: cleanEnv,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ })
+
+ child.on('exit', code => {
+ if (code === 0) {
+ resolve()
+ return
+ }
+
+ reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`))
+ })
+ child.on('error', reject)
+ })
+}
+
+const stageRoot = await mkdtemp(path.join(os.tmpdir(), 'nutrient-dws-mcpb-'))
+const stageDir = path.join(stageRoot, 'bundle')
+
+try {
+ await mkdir(stageDir, { recursive: true })
+ await cp(path.join(rootDir, 'dist'), path.join(stageDir, 'dist'), { recursive: true })
+ await cp(path.join(rootDir, 'package.json'), path.join(stageDir, 'package.json'))
+ await cp(path.join(rootDir, 'README.md'), path.join(stageDir, 'README.md'))
+ await cp(path.join(rootDir, 'LICENSE'), path.join(stageDir, 'LICENSE'))
+ await cp(path.join(rootDir, 'manifest.json'), path.join(stageDir, 'manifest.json'))
+
+ await run('npm', ['install', '--omit=dev', '--ignore-scripts', '--no-package-lock'], stageDir)
+ await rm(path.join(stageDir, 'node_modules', '.package-lock.json'), { force: true })
+ await run('npx', ['-y', '@anthropic-ai/mcpb', 'validate', 'manifest.json'], stageDir)
+
+ await mkdir(path.dirname(outputPath), { recursive: true })
+ await run('npx', ['-y', '@anthropic-ai/mcpb', 'pack', '.', outputPath], stageDir)
+ await run('npx', ['-y', '@anthropic-ai/mcpb', 'info', outputPath], stageDir)
+} finally {
+ await rm(stageRoot, { recursive: true, force: true })
+}
diff --git a/scripts/sync-manifest-version.mjs b/scripts/sync-manifest-version.mjs
new file mode 100644
index 0000000..2ed22d2
--- /dev/null
+++ b/scripts/sync-manifest-version.mjs
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+
+import { readFile, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const rootDir = path.resolve(__dirname, '..')
+const packageJsonPath = path.join(rootDir, 'package.json')
+const manifestJsonPath = path.join(rootDir, 'manifest.json')
+
+const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
+const manifestJson = JSON.parse(await readFile(manifestJsonPath, 'utf8'))
+
+if (manifestJson.version !== packageJson.version) {
+ manifestJson.version = packageJson.version
+ await writeFile(manifestJsonPath, `${JSON.stringify(manifestJson, null, 2)}\n`, 'utf8')
+}
diff --git a/src/auth/nutrient-oauth.ts b/src/auth/nutrient-oauth.ts
index 9203379..4876c51 100644
--- a/src/auth/nutrient-oauth.ts
+++ b/src/auth/nutrient-oauth.ts
@@ -40,6 +40,7 @@ type CachedCredentials = z.infer
const FETCH_TIMEOUT_MS = 15_000
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
+const pendingTokenRequests = new Map>()
export function getDefaultCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
@@ -367,7 +368,24 @@ export async function invalidateCachedToken(config: NutrientOAuthConfig): Promis
export async function getToken(config: NutrientOAuthConfig): Promise {
const credentialsPath = config.credentialsPath ?? getDefaultCredentialsPath()
- logger.debug('getToken called', { credentialsPath })
+ const pendingRequest = pendingTokenRequests.get(credentialsPath)
+ if (pendingRequest) {
+ logger.debug('Awaiting in-flight token acquisition', { credentialsPath })
+ return pendingRequest
+ }
+
+ const tokenRequest = getTokenUncached(config, credentialsPath).finally(() => {
+ if (pendingTokenRequests.get(credentialsPath) === tokenRequest) {
+ pendingTokenRequests.delete(credentialsPath)
+ }
+ })
+
+ pendingTokenRequests.set(credentialsPath, tokenRequest)
+ return tokenRequest
+}
+
+async function getTokenUncached(config: NutrientOAuthConfig, credentialsPath: string): Promise {
+ logger.debug('Starting token acquisition', { credentialsPath })
// 1. Check cached token
const cached = await readCachedCredentials(credentialsPath)
diff --git a/src/index.ts b/src/index.ts
index 8a4f94e..78dff1b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -41,7 +41,7 @@ function addToolsToServer(options: {
server.tool(
'document_processor',
- `Processes documents using Nutrient DWS Processor API. Reads from and writes to file system or sandbox (if enabled).
+ `Process, convert, and transform documents using the Nutrient API. Reads input files from the local file system or sandbox (if enabled) and writes results back locally.
Features:
• Import XFDF annotations
@@ -53,6 +53,13 @@ Features:
Output formats: PDF, PDF/A, images (PNG, JPEG, WebP), JSON extraction, Office (DOCX, XLSX, PPTX)`,
BuildAPIArgsSchema.shape,
+ {
+ title: 'Nutrient Document Processor',
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ openWorldHint: true,
+ },
async ({ instructions, outputPath }) => {
try {
return await performBuildCall(instructions, outputPath, apiClient)
@@ -64,7 +71,7 @@ Output formats: PDF, PDF/A, images (PNG, JPEG, WebP), JSON extraction, Office (D
server.tool(
'document_signer',
- `Digitally signs PDF files using Nutrient DWS Sign API. Reads from and writes to file system or sandbox (if enabled).
+ `Digitally sign PDF files using the Nutrient Sign API. Reads input files from the local file system or sandbox (if enabled) and writes signed output back locally.
Signature types:
• CMS/PKCS#7 (standard digital signatures)
@@ -80,6 +87,13 @@ Positioning:
• Place on specific page coordinates
• Use existing signature form fields`,
SignAPIArgsSchema.shape,
+ {
+ title: 'Nutrient Document Signer',
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ openWorldHint: true,
+ },
async ({ filePath, signatureOptions, watermarkImagePath, graphicImagePath, outputPath }) => {
try {
return await performSignCall(
@@ -98,7 +112,7 @@ Positioning:
server.tool(
'ai_redactor',
- `AI-powered document redaction using Nutrient DWS AI Redaction API. Reads from and writes to file system or sandbox (if enabled).
+ `Detect and permanently redact sensitive content using the Nutrient AI Redaction API. Reads input files from the local file system or sandbox (if enabled) and writes redacted output back locally.
Automatically detects and permanently removes sensitive information from documents using AI analysis.
Detected content types include:
@@ -110,6 +124,13 @@ Detected content types include:
By default (when neither stage nor apply is set), redactions are detected and immediately applied. Set stage to true to detect and stage redactions without applying them. Set apply to true to apply previously staged redactions.`,
AiRedactArgsSchema.shape,
+ {
+ title: 'Nutrient AI Redactor',
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ openWorldHint: true,
+ },
async ({ filePath, criteria, outputPath, stage, apply }) => {
try {
return await performAiRedactCall(filePath, criteria, outputPath, apiClient, stage, apply)
@@ -123,8 +144,17 @@ By default (when neither stage nor apply is set), redactions are detected and im
'check_credits',
`Check your Nutrient DWS API credit balance and usage for the current billing period.
+This is a read-only account lookup. It does not upload any document content.
+
Returns: subscription type, total credits, used credits, and remaining credits.`,
CheckCreditsArgsSchema.shape,
+ {
+ title: 'Nutrient Credit Balance',
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: true,
+ },
async () => {
try {
return await performCheckCreditsCall(apiClient)
@@ -137,21 +167,35 @@ Returns: subscription type, total credits, used credits, and remaining credits.`
if (sandboxEnabled) {
server.tool(
'sandbox_file_tree',
- 'Returns the file tree of the sandbox directory. It will recurse into subdirectories and return a list of files and directories.',
+ 'Browse files already available in the configured sandbox directory. This is a read-only local filesystem operation and does not upload documents to Nutrient.',
{},
+ {
+ title: 'Nutrient Sandbox Files',
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
async () => performDirectoryTreeCall('.'),
)
} else {
server.tool(
'directory_tree',
- 'Returns the directory tree of a given path. All paths are resolved relative to root directory.',
+ 'Browse local files when sandbox mode is disabled. This is a read-only local filesystem operation, but it can inspect any path visible to the current user. Sandbox mode is strongly recommended.',
DirectoryTreeArgsSchema.shape,
+ {
+ title: 'Nutrient Directory Tree',
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
async ({ path }) => performDirectoryTreeCall(path),
)
}
}
-function createMcpServer(options: { sandboxEnabled: boolean; apiClient: DwsApiClient }) {
+export function createMcpServer(options: { sandboxEnabled: boolean; apiClient: DwsApiClient }) {
const server = new McpServer(
{
name: 'nutrient-dws-mcp-server',
@@ -240,14 +284,6 @@ export async function runServer(environment: Environment): Promise 1) {
- return args[1]
- }
-
- throw new Error('--sandbox flag requires a directory path')
- }
+ let sandboxPath: string | undefined
- // Check command line arguments first (higher precedence)
- for (let i = 1; i < argsLength; i++) {
+ for (let i = 0; i < args.length; i++) {
const arg = args[i]
+
if (arg === '--sandbox' || arg === '-s') {
- if (i + 1 < argsLength) {
- return args[i + 1]
+ if (i + 1 < args.length) {
+ sandboxPath = args[i + 1]
+ i += 1
+ continue
}
throw new Error('--sandbox flag requires a directory path')
}
+
+ if (arg.startsWith('-')) {
+ throw new Error(`Unknown CLI flag: ${arg}`)
+ }
+
+ throw new Error(`Unexpected argument: ${arg}`)
}
- // Fall back to environment variable
- return envVar || undefined
+ return sandboxPath || envVar || undefined
}
diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts
new file mode 100644
index 0000000..5ca2abe
--- /dev/null
+++ b/tests/mcp-tools.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import { createMcpServer } from '../src/index.js'
+import type { DwsApiClient } from '../src/dws/client.js'
+
+function createMockApiClient(): DwsApiClient {
+ return {
+ post: async () => {
+ throw new Error('not implemented')
+ },
+ get: async () => {
+ throw new Error('not implemented')
+ },
+ } as unknown as DwsApiClient
+}
+
+type RegisteredTool = {
+ annotations?: {
+ title?: string
+ readOnlyHint?: boolean
+ destructiveHint?: boolean
+ }
+}
+
+function getRegisteredTools(sandboxEnabled: boolean): Record {
+ const server = createMcpServer({
+ sandboxEnabled,
+ apiClient: createMockApiClient(),
+ })
+
+ return (server as unknown as { _registeredTools: Record })._registeredTools
+}
+
+describe('MCP tool metadata', () => {
+ it('assigns titles and safety annotations to all sandbox-enabled tools', () => {
+ const tools = getRegisteredTools(true)
+
+ expect(Object.keys(tools).sort()).toEqual([
+ 'ai_redactor',
+ 'check_credits',
+ 'document_processor',
+ 'document_signer',
+ 'sandbox_file_tree',
+ ])
+
+ for (const [name, tool] of Object.entries(tools)) {
+ expect(tool.annotations, `${name} is missing annotations`).toBeTruthy()
+ expect(tool.annotations?.title, `${name} is missing a title`).toBeTruthy()
+
+ const isReadOnly = tool.annotations?.readOnlyHint === true
+ const isDestructive = tool.annotations?.destructiveHint === true
+ expect(Number(isReadOnly) + Number(isDestructive), `${name} must be either read-only or destructive`).toBe(1)
+ }
+ })
+
+ it('registers directory_tree with safety annotations when sandbox mode is disabled', () => {
+ const tools = getRegisteredTools(false)
+ const directoryTree = tools.directory_tree
+
+ expect(directoryTree).toBeTruthy()
+ expect(directoryTree?.annotations?.title).toBeTruthy()
+ expect(directoryTree?.annotations?.readOnlyHint).toBe(true)
+ expect(directoryTree?.annotations?.destructiveHint).not.toBe(true)
+ })
+})
diff --git a/tests/nutrient-oauth.test.ts b/tests/nutrient-oauth.test.ts
index 3ecdec4..b063037 100644
--- a/tests/nutrient-oauth.test.ts
+++ b/tests/nutrient-oauth.test.ts
@@ -325,10 +325,29 @@ describe('getToken integration', () => {
expect(token).toBe('concurrent-new-token')
}
- // NOTE: Without dedup, each call makes its own refresh request.
- // This test documents the current behavior. If dedup is added,
- // change the assertion to: expect(refreshCallCount).toBe(1)
- expect(refreshCallCount).toBeGreaterThanOrEqual(1)
+ expect(refreshCallCount).toBe(1)
+ })
+
+ it('concurrent calls start the browser flow only once when no credentials exist', async () => {
+ const { getToken } = await import('../src/auth/nutrient-oauth.js')
+ const config = makeConfig({
+ tokenUrl: 'http://localhost:1/token',
+ credentialsPath: join(testDir, 'nonexistent.json'),
+ clientId: 'fresh-client',
+ })
+
+ const openMock = (await import('open')).default as ReturnType
+ openMock.mockClear()
+
+ const first = getToken(config)
+ const second = getToken(config)
+
+ await vi.waitFor(() => {
+ expect(openMock).toHaveBeenCalledOnce()
+ }, { timeout: 5_000 })
+
+ first.catch(() => {})
+ second.catch(() => {})
})
it('goes straight to browser flow when no refresh token is cached', async () => {
diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts
new file mode 100644
index 0000000..5950fe2
--- /dev/null
+++ b/tests/package-metadata.test.ts
@@ -0,0 +1,17 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as {
+ version: string
+}
+
+const manifestJson = JSON.parse(readFileSync(resolve(process.cwd(), 'manifest.json'), 'utf8')) as {
+ version: string
+}
+
+describe('package metadata', () => {
+ it('keeps package.json and manifest.json versions in sync', () => {
+ expect(manifestJson.version).toBe(packageJson.version)
+ })
+})
diff --git a/tests/unit.test.ts b/tests/unit.test.ts
index fc4c2a5..397a547 100644
--- a/tests/unit.test.ts
+++ b/tests/unit.test.ts
@@ -766,10 +766,19 @@ describe('API Functions', () => {
expect(() => parseSandboxPath(args, undefined)).toThrow('--sandbox flag requires a directory path')
})
- it('should handle multiple arguments and find sandbox flag', () => {
- const args = ['--help', '--sandbox', '/path/to/sandbox', '--verbose']
- const result = parseSandboxPath(args, undefined)
- expect(result).toBe('/path/to/sandbox')
+ it('should throw on unknown flags', () => {
+ const args = ['--sandbox-dir', '/path/to/sandbox']
+ expect(() => parseSandboxPath(args, undefined)).toThrow('Unknown CLI flag: --sandbox-dir')
+ })
+
+ it('should throw on unexpected positional arguments', () => {
+ const args = ['/path/to/sandbox']
+ expect(() => parseSandboxPath(args, undefined)).toThrow('Unexpected argument: /path/to/sandbox')
+ })
+
+ it('should throw when known and unknown flags are mixed', () => {
+ const args = ['--sandbox', '/path/to/sandbox', '--verbose']
+ expect(() => parseSandboxPath(args, undefined)).toThrow('Unknown CLI flag: --verbose')
})
it('should return undefined when empty env var provided', () => {