From c7701d03b0247f78bdb794b90bf0bfaf028d845f Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Sat, 7 Mar 2026 01:27:39 -0500 Subject: [PATCH 1/3] feat(mcp): add authenticated http transport Add streamable HTTP MCP support alongside stdio mode for the DWS connector.\n\nThis adds bearer-authenticated remote transport, session-bound tool filtering, health checks, targeted transport tests, and GHCR publish automation for Hosted deployment. --- .dockerignore | 12 + .github/workflows/ci.yml | 43 ++ .github/workflows/container-publish.yml | 104 +++++ Dockerfile | 2 +- README.md | 75 ++++ package.json | 5 + pnpm-lock.yaml | 198 ++++++++++ src/http/bearerAuth.ts | 138 +++++++ src/index.ts | 502 +++++++++++++++++++----- src/utils/environment.ts | 125 ++++++ tests/bearerAuth.test.ts | 53 +++ tests/build-api-examples.test.ts | 5 +- tests/environment.test.ts | 119 ++++++ tests/httpTransport.test.ts | 192 +++++++++ tests/signing-api-examples.test.ts | 5 +- 15 files changed, 1471 insertions(+), 107 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/container-publish.yml create mode 100644 src/http/bearerAuth.ts create mode 100644 src/utils/environment.ts create mode 100644 tests/bearerAuth.test.ts create mode 100644 tests/environment.test.ts create mode 100644 tests/httpTransport.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2bcee3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +node_modules +dist +coverage +.DS_Store +.env +.env.* +tests +resources +docs +*.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b61a051 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 20 + cache: pnpm + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test diff --git a/.github/workflows/container-publish.yml b/.github/workflows/container-publish.yml new file mode 100644 index 0000000..1114aa2 --- /dev/null +++ b/.github/workflows/container-publish.yml @@ -0,0 +1,104 @@ +name: Container Publish + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +concurrency: + group: container-publish-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ghcr.io/pspdfkit/nutrient-dws-mcp-server + +jobs: + verify: + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 20 + cache: pnpm + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + publish: + runs-on: ubuntu-24.04 + needs: verify + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image metadata + id: meta + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/Dockerfile b/Dockerfile index 007c75b..6e683ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,5 +23,5 @@ COPY --from=builder /app/dist ./dist RUN chown -R appuser:appgroup /app USER appuser -# MCP runs over stdio +# Transport is selected at runtime via MCP_TRANSPORT. ENTRYPOINT ["node", "dist/index.js"] diff --git a/README.md b/README.md index 463d4c7..48d612f 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,58 @@ NUTRIENT_DWS_API_KEY=your_key SANDBOX_PATH=/your/path npx @nutrient-sdk/dws-mcp- ``` +### Remote HTTP Mode + +For hosted or Co-work style deployments, the server also supports Streamable HTTP: + +```bash +MCP_TRANSPORT=http \ +MCP_HOST=0.0.0.0 \ +PORT=5100 \ +MCP_ALLOWED_HOSTS=connector.internal.example.com,connector.example.com \ +MCP_BEARER_TOKENS_JSON='[{"token":"replace-me","clientId":"claude-cowork-prod","scopes":["mcp"],"allowedTools":["document_processor","document_signer","ai_redactor","check_credits"]}]' \ +NUTRIENT_DWS_API_KEY=your_key \ +node dist/index.js +``` + +Remote mode exposes: + +- `POST /mcp` for initialize and request traffic +- `GET /mcp` for server-to-client event streams +- `DELETE /mcp` for session teardown +- `GET /health` for load balancer and readiness checks + +Security model: + +- bearer-token auth is required for every `/mcp` request +- sessions are bound to the authenticated principal +- the advertised MCP tool list is filtered per principal at session initialization +- stdio mode remains unchanged for local desktop clients + +### Published Container Image + +This repo is the image owner for remote deployments. + +- registry: `ghcr.io/pspdfkit/nutrient-dws-mcp-server` +- `latest`: current default-branch image +- `sha-`: immutable commit image +- `vX.Y.Z`: release tag image + +For Hosted-style deployments, use an immutable image tag in `TF_VAR_dws_mcp_docker_image` and keep the bearer-principal policy in environment-injected secrets. + +Example: + +```bash +docker run --rm -p 5100:5100 \ + -e MCP_TRANSPORT=http \ + -e MCP_HOST=0.0.0.0 \ + -e PORT=5100 \ + -e MCP_ALLOWED_HOSTS=mcp.example.com \ + -e MCP_BEARER_TOKENS_JSON='[{"token":"replace-me","clientId":"claude-cowork-prod","scopes":["mcp"],"allowedTools":["document_processor","document_signer","check_credits"]}]' \ + -e NUTRIENT_DWS_API_KEY=your_key \ + ghcr.io/pspdfkit/nutrient-dws-mcp-server:sha- +``` + ### 3. Restart Your AI Client Restart the application to pick up the new MCP server configuration. @@ -228,6 +280,25 @@ Processed files are saved to a location determined by the AI. To guide output pl |----------|----------|-------------| | `NUTRIENT_DWS_API_KEY` | Yes | Your Nutrient DWS API key ([get one free](https://dashboard.nutrient.io/sign_up/)) | | `SANDBOX_PATH` | Recommended | Directory to restrict file operations to | +| `MCP_TRANSPORT` | No | `stdio` (default) or `http` | +| `MCP_HOST` | HTTP only | Host to bind the HTTP server to. Defaults to `127.0.0.1` | +| `PORT` | HTTP only | Port for HTTP mode. Defaults to `5100` | +| `MCP_ALLOWED_HOSTS` | Optional | Comma-separated allowed `Host` headers when binding beyond loopback | +| `MCP_BEARER_TOKEN` | HTTP only | Single inbound bearer token for `/mcp` | +| `MCP_BEARER_TOKEN_CLIENT_ID` | Optional | Principal ID paired with `MCP_BEARER_TOKEN` | +| `MCP_BEARER_TOKEN_SCOPES` | Optional | Comma-separated scopes paired with `MCP_BEARER_TOKEN` | +| `MCP_BEARER_TOKEN_ALLOWED_TOOLS` | Optional | Comma-separated tool allowlist paired with `MCP_BEARER_TOKEN` | +| `MCP_BEARER_TOKENS_JSON` | HTTP only | JSON array of principal configs for multi-token remote deployments | + +### Secret Management + +Do not commit bearer tokens or DWS API keys into repo config. + +Recommended pattern: + +- local/dev: inject env vars directly, or source them from 1Password CLI / `op://...` references +- hosted environments: inject secrets through the platform secret store into container env vars +- rotate remote bearer tokens by supplying overlapping entries in `MCP_BEARER_TOKENS_JSON` ## Troubleshooting @@ -245,6 +316,10 @@ Processed files are saved to a location determined by the AI. To guide output pl - Ensure your documents are inside the sandbox directory - Use the `sandbox_file_tree` tool to verify visible files +**Running tests in CI or local dev?** +- `pnpm test` runs the deterministic local suite +- live DWS example suites are opt-in and require `RUN_LIVE_DWS_EXAMPLE_TESTS=1` + ## Contributing Please see the contribution guidelines in [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/package.json b/package.json index 11bda74..f878737 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "node": ">=18.0.0", "pnpm": ">=9.10.0" }, + "packageManager": "pnpm@10.18.2", "type": "module", "files": [ "dist", @@ -49,16 +50,20 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "axios": "^1.13.2", + "express": "^5.1.0", "form-data": "^4.0.5", "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/express": "^5.0.3", "@types/node": "^22.19.5", + "@types/supertest": "^6.0.3", "dotenv": "^16.6.1", "eslint": "^9.39.2", "prettier": "^3.7.4", "shx": "^0.4.0", + "supertest": "^7.1.4", "typescript": "^5.9.3", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a601ce..46df9c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: axios: specifier: ^1.13.2 version: 1.13.2 + express: + specifier: ^5.1.0 + version: 5.2.1 form-data: specifier: ^4.0.5 version: 4.0.5 @@ -24,9 +27,15 @@ importers: '@eslint/js': specifier: ^9.39.2 version: 9.39.2 + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 '@types/node': specifier: ^22.19.5 version: 22.19.5 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 dotenv: specifier: ^16.6.1 version: 16.6.1 @@ -39,6 +48,9 @@ importers: shx: specifier: ^0.4.0 version: 0.4.0 + supertest: + specifier: ^7.1.4 + version: 7.2.2 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -280,6 +292,10 @@ packages: '@cfworker/json-schema': optional: true + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -292,6 +308,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] @@ -420,21 +439,60 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@22.19.5': resolution: {integrity: sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==} + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@typescript-eslint/eslint-plugin@8.52.0': resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -558,6 +616,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -620,6 +681,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -639,6 +703,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -671,6 +738,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -808,6 +878,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -859,6 +932,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1039,6 +1116,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1059,6 +1140,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1337,6 +1423,14 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1680,6 +1774,8 @@ snapshots: - hono - supports-color + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1692,6 +1788,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@rollup/rollup-android-arm-eabi@4.55.1': optional: true @@ -1769,21 +1869,74 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.5 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.5 + + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 22.19.5 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + '@types/node@22.19.5': dependencies: undici-types: 6.21.0 + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.5 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.5 + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.5 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1949,6 +2102,8 @@ snapshots: argparse@2.0.1: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} @@ -2021,6 +2176,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + component-emitter@1.3.1: {} + concat-map@0.0.1: {} content-disposition@1.0.1: {} @@ -2031,6 +2188,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -2060,6 +2219,11 @@ snapshots: depd@2.0.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -2267,6 +2431,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -2318,6 +2484,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -2465,6 +2637,8 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -2482,6 +2656,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2766,6 +2942,28 @@ snapshots: strip-json-comments@3.1.1: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 diff --git a/src/http/bearerAuth.ts b/src/http/bearerAuth.ts new file mode 100644 index 0000000..8a241e4 --- /dev/null +++ b/src/http/bearerAuth.ts @@ -0,0 +1,138 @@ +import { createHash, timingSafeEqual } from 'node:crypto' +import type { Request, RequestHandler } from 'express' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import type { BearerPrincipalConfig } from '../utils/environment.js' + +const FILE_TREE_TOOL_NAMES = new Set(['directory_tree', 'sandbox_file_tree']) + +export interface PrincipalAuthExtra extends Record { + allowedTools?: string[] + principalFingerprint: string +} + +export type PrincipalAuthInfo = AuthInfo & { + extra?: PrincipalAuthExtra +} + +export type AuthenticatedRequest = Request & { + auth?: PrincipalAuthInfo +} + +function constantTimeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + + if (leftBuffer.length !== rightBuffer.length) { + return false + } + + return timingSafeEqual(leftBuffer, rightBuffer) +} + +function sendUnauthorized( + res: { + setHeader(name: string, value: string): void + status(code: number): { json(body: Record): void } + }, + error: string, + description: string, +) { + res.setHeader('WWW-Authenticate', `Bearer realm="nutrient-dws-mcp-server", error="${error}", error_description="${description}"`) + res.status(401).json({ error, message: description }) +} + +export function expandAllowedTools(allowedTools?: readonly string[]): string[] | undefined { + if (!allowedTools || allowedTools.length === 0) { + return undefined + } + + const expanded = new Set() + + for (const toolName of allowedTools) { + expanded.add(toolName) + + if (FILE_TREE_TOOL_NAMES.has(toolName)) { + expanded.add('directory_tree') + expanded.add('sandbox_file_tree') + } + } + + return [...expanded] +} + +export function createPrincipalFingerprint(clientId: string, token: string): string { + return createHash('sha256').update(`${clientId}:${token}`).digest('hex') +} + +export function getPrincipalFingerprint(authInfo?: AuthInfo): string | undefined { + const fingerprint = authInfo?.extra?.principalFingerprint + + if (typeof fingerprint === 'string' && fingerprint.length > 0) { + return fingerprint + } + + if (!authInfo?.clientId || !authInfo.token) { + return undefined + } + + return createPrincipalFingerprint(authInfo.clientId, authInfo.token) +} + +export function getAllowedTools(authInfo?: AuthInfo): string[] | undefined { + const allowedTools = authInfo?.extra?.allowedTools + + if (!Array.isArray(allowedTools)) { + return undefined + } + + return allowedTools.filter((tool): tool is string => typeof tool === 'string' && tool.trim().length > 0) +} + +export function isToolAllowed(toolName: string, authInfo?: AuthInfo): boolean { + const allowedTools = getAllowedTools(authInfo) + + if (!allowedTools || allowedTools.length === 0) { + return true + } + + const expanded = expandAllowedTools(allowedTools) + return expanded?.includes(toolName) ?? true +} + +export function createBearerAuthMiddleware(principals: readonly BearerPrincipalConfig[]): RequestHandler { + return (req, res, next) => { + const authorizationHeader = req.header('authorization') + + if (!authorizationHeader) { + sendUnauthorized(res, 'invalid_request', 'Missing Authorization header.') + return + } + + const [scheme, token] = authorizationHeader.split(/\s+/, 2) + + if (scheme !== 'Bearer' || !token) { + sendUnauthorized(res, 'invalid_request', 'Authorization header must use the Bearer scheme.') + return + } + + const principal = principals.find(candidate => constantTimeEquals(candidate.token, token)) + + if (!principal) { + sendUnauthorized(res, 'invalid_token', 'Bearer token is not recognized.') + return + } + + const authInfo: PrincipalAuthInfo = { + token, + clientId: principal.clientId, + scopes: principal.scopes, + extra: { + allowedTools: expandAllowedTools(principal.allowedTools), + principalFingerprint: createPrincipalFingerprint(principal.clientId, token), + }, + } + + ;(req as AuthenticatedRequest).auth = authInfo + next() + } +} diff --git a/src/index.ts b/src/index.ts index 4d7f537..3aae903 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,17 @@ * This server provides a Model Context Protocol (MCP) interface to the Nutrient DWS Processor API. */ +import type { Server as HttpServer } from 'node:http' +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Express, Request, Response } from 'express' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' import { AiRedactArgsSchema, BuildAPIArgsSchema, @@ -21,27 +30,64 @@ import { performAiRedactCall } from './dws/ai-redact.js' import { performCheckCreditsCall } from './dws/credits.js' import { performDirectoryTreeCall } from './fs/directoryTree.js' import { setSandboxDirectory } from './fs/sandbox.js' +import { createBearerAuthMiddleware, getAllowedTools, getPrincipalFingerprint, isToolAllowed, type AuthenticatedRequest } from './http/bearerAuth.js' import { createErrorResponse } from './responses.js' +import { getEnvironment, type ParsedEnvironment } from './utils/environment.js' import { getVersion } from './version.js' import { parseSandboxPath } from './utils/sandbox.js' -const server = new McpServer( - { +type AllowedToolSet = ReadonlySet | undefined + +interface HttpSessionContext { + principalFingerprint?: string + server: McpServer + transport: StreamableHTTPServerTransport +} + +export interface HttpAppContext { + app: Express + close: () => Promise +} + +interface RuntimeOptions { + env?: ParsedEnvironment + sandboxDir?: string | null +} + +function createServerInfo() { + return { name: 'nutrient-dws-mcp-server', version: getVersion(), - }, - { - capabilities: { - tools: {}, - logging: {}, - }, - }, -) + } +} + +function shouldRegisterTool(toolName: string, allowedTools: AllowedToolSet): boolean { + return allowedTools?.has(toolName) ?? true +} + +function toolErrorMessage(error: unknown): string { + return `Error: ${error instanceof Error ? error.message : String(error)}` +} + +function unauthorizedTool(toolName: string) { + return createErrorResponse(`Tool "${toolName}" is not permitted for this principal.`) +} + +function createAllowedToolSet(allowedTools?: readonly string[]): AllowedToolSet { + if (!allowedTools || allowedTools.length === 0) { + return undefined + } + + return new Set(allowedTools) +} -function addToolsToServer(server: McpServer, sandboxEnabled: boolean = false) { - server.tool( - 'document_processor', - `Processes documents using Nutrient DWS Processor API. Reads from and writes to file system or sandbox (if enabled). +function addToolsToServer(server: McpServer, sandboxEnabled: boolean, allowedTools?: readonly string[]) { + const allowedToolSet = createAllowedToolSet(allowedTools) + + if (shouldRegisterTool('document_processor', allowedToolSet)) { + server.tool( + 'document_processor', + `Processes documents using Nutrient DWS Processor API. Reads from and writes to file system or sandbox (if enabled). Features: • Import XFDF annotations @@ -52,19 +98,25 @@ Features: • Redaction creation and application Output formats: PDF, PDF/A, images (PNG, JPEG, WebP), JSON extraction, Office (DOCX, XLSX, PPTX)`, - BuildAPIArgsSchema.shape, - async ({ instructions, outputPath }) => { - try { - return performBuildCall(instructions, outputPath) - } catch (error) { - return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - }, - ) + BuildAPIArgsSchema.shape, + async ({ instructions, outputPath }, extra) => { + if (!isToolAllowed('document_processor', extra.authInfo)) { + return unauthorizedTool('document_processor') + } + + try { + return await performBuildCall(instructions, outputPath) + } catch (error) { + return createErrorResponse(toolErrorMessage(error)) + } + }, + ) + } - server.tool( - 'document_signer', - `Digitally signs PDF files using Nutrient DWS Sign API. Reads from and writes to file system or sandbox (if enabled). + if (shouldRegisterTool('document_signer', allowedToolSet)) { + server.tool( + 'document_signer', + `Digitally signs PDF files using Nutrient DWS Sign API. Reads from and writes to file system or sandbox (if enabled). Signature types: • CMS/PKCS#7 (standard digital signatures) @@ -79,19 +131,25 @@ Appearance options: Positioning: • Place on specific page coordinates • Use existing signature form fields`, - SignAPIArgsSchema.shape, - async ({ filePath, signatureOptions, watermarkImagePath, graphicImagePath, outputPath }) => { - try { - return performSignCall(filePath, outputPath, signatureOptions, watermarkImagePath, graphicImagePath) - } catch (error) { - return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - }, - ) + SignAPIArgsSchema.shape, + async ({ filePath, signatureOptions, watermarkImagePath, graphicImagePath, outputPath }, extra) => { + if (!isToolAllowed('document_signer', extra.authInfo)) { + return unauthorizedTool('document_signer') + } + + try { + return await performSignCall(filePath, outputPath, signatureOptions, watermarkImagePath, graphicImagePath) + } catch (error) { + return createErrorResponse(toolErrorMessage(error)) + } + }, + ) + } - 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). + if (shouldRegisterTool('ai_redactor', allowedToolSet)) { + 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). Automatically detects and permanently removes sensitive information from documents using AI analysis. Detected content types include: @@ -102,105 +160,341 @@ Detected content types include: • Any custom criteria you specify 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, - async ({ filePath, criteria, outputPath, stage, apply }) => { - try { - return performAiRedactCall(filePath, criteria, outputPath, stage, apply) - } catch (error) { - return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - }, - ) + AiRedactArgsSchema.shape, + async ({ filePath, criteria, outputPath, stage, apply }, extra) => { + if (!isToolAllowed('ai_redactor', extra.authInfo)) { + return unauthorizedTool('ai_redactor') + } + + try { + return await performAiRedactCall(filePath, criteria, outputPath, stage, apply) + } catch (error) { + return createErrorResponse(toolErrorMessage(error)) + } + }, + ) + } - server.tool( - 'check_credits', - `Check your Nutrient DWS API credit balance and usage for the current billing period. + if (shouldRegisterTool('check_credits', allowedToolSet)) { + server.tool( + 'check_credits', + `Check your Nutrient DWS API credit balance and usage for the current billing period. Returns: subscription type, total credits, used credits, and remaining credits.`, - CheckCreditsArgsSchema.shape, - async () => { - try { - return performCheckCreditsCall() - } catch (error) { - return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - }, - ) + CheckCreditsArgsSchema.shape, + async (_args, extra) => { + if (!isToolAllowed('check_credits', extra.authInfo)) { + return unauthorizedTool('check_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.', - {}, - async () => performDirectoryTreeCall('.'), + try { + return await performCheckCreditsCall() + } catch (error) { + return createErrorResponse(toolErrorMessage(error)) + } + }, ) - } else { + } + + if (sandboxEnabled) { + if (shouldRegisterTool('sandbox_file_tree', allowedToolSet)) { + 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.', + {}, + async (_args, extra) => { + if (!isToolAllowed('sandbox_file_tree', extra.authInfo)) { + return unauthorizedTool('sandbox_file_tree') + } + + return performDirectoryTreeCall('.') + }, + ) + } + } else if (shouldRegisterTool('directory_tree', allowedToolSet)) { server.tool( 'directory_tree', 'Returns the directory tree of a given path. All paths are resolved relative to root directory.', DirectoryTreeArgsSchema.shape, - async ({ path }) => performDirectoryTreeCall(path), + async ({ path }, extra) => { + if (!isToolAllowed('directory_tree', extra.authInfo)) { + return unauthorizedTool('directory_tree') + } + + return performDirectoryTreeCall(path) + }, ) } } -async function parseCommandLineArgs() { +export function createMcpServer(options: { sandboxEnabled?: boolean; allowedTools?: readonly string[] } = {}) { + const { sandboxEnabled = false, allowedTools } = options + + const server = new McpServer(createServerInfo(), { + capabilities: { + tools: {}, + logging: {}, + }, + }) + + addToolsToServer(server, sandboxEnabled, allowedTools) + return server +} + +function parseCommandLineArgs() { const args = process.argv.slice(2) + return { + sandboxDir: parseSandboxPath(args, process.env.SANDBOX_PATH) || null, + } +} - try { - const sandboxDir = parseSandboxPath(args, process.env.SANDBOX_PATH) || null - return { sandboxDir } - } catch (error) { - await server.server.sendLoggingMessage({ - level: 'error', - data: `Error: ${error instanceof Error ? error.message : String(error)}`, - }) - process.exit(1) +async function configureSandbox(sandboxDir: string | null) { + if (!sandboxDir) { + console.warn( + 'Info: No sandbox directory specified. File operations will not be restricted.\n' + + 'Sandboxed mode is recommended - To enable sandboxed mode and restrict file operations, set SANDBOX_PATH environment variable', + ) + return + } + + await setSandboxDirectory(sandboxDir) +} + +function getSessionId(headerValue: string | string[] | undefined): string | undefined { + if (Array.isArray(headerValue)) { + return headerValue[0] } + + return headerValue } -export async function runServer() { - const { sandboxDir } = await parseCommandLineArgs() +function sendJsonRpcError(res: Response, statusCode: number, code: number, message: string) { + res.status(statusCode).json({ + jsonrpc: '2.0', + error: { code, message }, + id: null, + }) +} + +function sendHttpError(res: Response, statusCode: number, message: string) { + res.status(statusCode).json({ error: message }) +} + +function samePrincipal(session: HttpSessionContext, authInfo?: AuthInfo): boolean { + const sessionFingerprint = session.principalFingerprint + const requestFingerprint = getPrincipalFingerprint(authInfo) + + if (!sessionFingerprint || !requestFingerprint) { + return sessionFingerprint === requestFingerprint + } + + return sessionFingerprint === requestFingerprint +} + +function resolveSession( + req: AuthenticatedRequest, + res: Response, + sessions: Map, +): HttpSessionContext | undefined { + const sessionId = getSessionId(req.headers['mcp-session-id']) + + if (!sessionId) { + sendHttpError(res, 400, 'Missing mcp-session-id header.') + return undefined + } + + const session = sessions.get(sessionId) + + if (!session) { + sendHttpError(res, 404, 'Unknown MCP session.') + return undefined + } + + if (!samePrincipal(session, req.auth)) { + sendHttpError(res, 403, 'Session belongs to a different principal.') + return undefined + } + + return session +} + +export function createHttpApp(options: RuntimeOptions = {}): HttpAppContext { + const env = options.env ?? getEnvironment() + const sandboxEnabled = options.sandboxDir !== null && options.sandboxDir !== undefined + const app = createMcpExpressApp({ + host: env.MCP_HOST, + allowedHosts: env.MCP_ALLOWED_HOSTS.length > 0 ? env.MCP_ALLOWED_HOSTS : undefined, + }) + + const sessions = new Map() + + app.get('/health', (_req, res) => { + res.status(200).json({ + status: 'ok', + name: createServerInfo().name, + version: getVersion(), + transport: 'http', + sandboxEnabled, + }) + }) + + app.use('/mcp', createBearerAuthMiddleware(env.AUTH_PRINCIPALS)) + + app.post('/mcp', async (req: Request, res: Response) => { + const authenticatedRequest = req as AuthenticatedRequest - if (sandboxDir) { try { - await setSandboxDirectory(sandboxDir) + const sessionId = getSessionId(authenticatedRequest.headers['mcp-session-id']) + + if (sessionId) { + const session = resolveSession(authenticatedRequest, res, sessions) + + if (!session) { + return + } + + await session.transport.handleRequest(authenticatedRequest, res, authenticatedRequest.body) + return + } + + if (!isInitializeRequest(authenticatedRequest.body)) { + sendJsonRpcError(res, 400, -32000, 'Bad Request: initialize is required when no session is provided.') + return + } + + const allowedTools = getAllowedTools(authenticatedRequest.auth) + const server = createMcpServer({ sandboxEnabled, allowedTools }) + const transport = new StreamableHTTPServerTransport({ + enableJsonResponse: true, + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: initializedSessionId => { + sessions.set(initializedSessionId, { + principalFingerprint: getPrincipalFingerprint(authenticatedRequest.auth), + server, + transport, + }) + }, + }) + + transport.onclose = () => { + if (transport.sessionId) { + sessions.delete(transport.sessionId) + } + } + + await server.connect(transport) + await transport.handleRequest(authenticatedRequest, res, authenticatedRequest.body) } catch (error) { - console.error(`Error setting sandbox directory: ${error instanceof Error ? error.message : String(error)}`) - process.exit(1) + if (!res.headersSent) { + sendJsonRpcError(res, 500, -32603, `Internal server error: ${error instanceof Error ? error.message : String(error)}`) + } } - } else { - console.warn( - 'Info: No sandbox directory specified. File operations will not be restricted.\n' + - 'Sandboxed mode is recommended - To enable sandboxed mode and restrict file operations, set SANDBOX_PATH environment variable', - ) + }) + + const handleSessionRequest = async (req: Request, res: Response) => { + const authenticatedRequest = req as AuthenticatedRequest + const session = resolveSession(authenticatedRequest, res, sessions) + + if (!session) { + return + } + + await session.transport.handleRequest(authenticatedRequest, res) } - addToolsToServer(server, sandboxDir !== null) + app.get('/mcp', handleSessionRequest) + app.delete('/mcp', handleSessionRequest) + + return { + app, + close: async () => { + const sessionServers = [...new Set([...sessions.values()].map(session => session.server))] + sessions.clear() + await Promise.all(sessionServers.map(server => server.close().catch(() => undefined))) + }, + } +} +export async function startStdioServer(options: RuntimeOptions = {}) { + const sandboxEnabled = options.sandboxDir !== null && options.sandboxDir !== undefined + const server = createMcpServer({ sandboxEnabled }) const transport = new StdioServerTransport() + + process.once('SIGINT', async () => { + await server.close() + process.exit(0) + }) + + process.stdin.once('close', async () => { + await server.close() + }) + await server.connect(transport) + await server.server.sendLoggingMessage({ + level: 'info', + data: `Nutrient DWS MCP Server ${getVersion()} running.`, + }) return server } -runServer() - .then(async (server) => { - server.server.getClientCapabilities() - await server.server.sendLoggingMessage({ - level: 'info', - data: `Nutrient DWS MCP Server ${getVersion()} running.`, +export async function startHttpServer(options: RuntimeOptions = {}) { + const env = options.env ?? getEnvironment() + const { app, close } = createHttpApp({ ...options, env }) + + const httpServer = await new Promise((resolve, reject) => { + const listener = app + .listen(env.PORT, env.MCP_HOST, () => resolve(listener)) + .on('error', reject) + }) + + const shutdown = async () => { + await close() + await new Promise((resolve, reject) => { + httpServer.close(error => { + if (error) { + reject(error) + return + } + + resolve() + }) }) + } + + process.once('SIGINT', async () => { + await shutdown() + process.exit(0) }) - .catch((error) => { + + console.info(`Nutrient DWS MCP Server ${getVersion()} running on HTTP at http://${env.MCP_HOST}:${env.PORT}/mcp`) + + return { app, httpServer, close: shutdown } +} + +export async function runServer(options: RuntimeOptions = {}) { + const env = options.env ?? getEnvironment() + const runtimeOptions = { ...options, env } + + await configureSandbox(runtimeOptions.sandboxDir ?? null) + + if (env.MCP_TRANSPORT === 'http') { + return startHttpServer(runtimeOptions) + } + + return startStdioServer(runtimeOptions) +} + +async function main() { + const { sandboxDir } = parseCommandLineArgs() + await runServer({ sandboxDir }) +} + +const entrypointPath = process.argv[1] ? resolve(process.argv[1]) : null + +if (entrypointPath === fileURLToPath(import.meta.url)) { + main().catch(error => { console.error('Fatal error running server:', error) process.exit(1) }) - -process.stdin.on('close', async () => { - await server.server.sendLoggingMessage({ - level: 'info', - data: `Nutrient DWS MCP Server ${getVersion()} closed.`, - }) - await server.close() -}) +} diff --git a/src/utils/environment.ts b/src/utils/environment.ts new file mode 100644 index 0000000..03c40fe --- /dev/null +++ b/src/utils/environment.ts @@ -0,0 +1,125 @@ +import { z } from 'zod' + +const rawEnvironmentSchema = z.object({ + MCP_TRANSPORT: z.enum(['stdio', 'http']).default('stdio'), + MCP_HOST: z.string().default('127.0.0.1'), + PORT: z.coerce.number().int().min(1).max(65535).default(5100), + MCP_ALLOWED_HOSTS: z.string().optional(), + MCP_BEARER_TOKEN: z.string().optional(), + MCP_BEARER_TOKEN_CLIENT_ID: z.string().default('cowork'), + MCP_BEARER_TOKEN_SCOPES: z.string().optional(), + MCP_BEARER_TOKEN_ALLOWED_TOOLS: z.string().optional(), + MCP_BEARER_TOKENS_JSON: z.string().optional(), +}) + +const bearerPrincipalSchema = z.object({ + token: z.string().min(1, 'token is required'), + clientId: z.string().min(1, 'clientId is required'), + scopes: z.array(z.string().min(1)).default([]), + allowedTools: z.array(z.string().min(1)).optional(), +}) + +type RawEnvironment = z.infer + +export type BearerPrincipalConfig = z.infer + +export interface ParsedEnvironment { + MCP_TRANSPORT: 'stdio' | 'http' + MCP_HOST: string + PORT: number + MCP_ALLOWED_HOSTS: string[] + AUTH_PRINCIPALS: BearerPrincipalConfig[] +} + +function parseCsvList(value?: string): string[] { + if (!value) { + return [] + } + + return value + .split(',') + .map(entry => entry.trim()) + .filter(Boolean) +} + +function parseJsonPrincipals(value?: string): BearerPrincipalConfig[] { + if (!value) { + return [] + } + + let parsed: unknown + + try { + parsed = JSON.parse(value) + } catch (error) { + throw new Error(`MCP_BEARER_TOKENS_JSON must be valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + + return z.array(bearerPrincipalSchema).parse(parsed) +} + +function parsePrincipals(env: RawEnvironment): BearerPrincipalConfig[] { + const principals = parseJsonPrincipals(env.MCP_BEARER_TOKENS_JSON) + + if (env.MCP_BEARER_TOKEN) { + principals.unshift({ + token: env.MCP_BEARER_TOKEN, + clientId: env.MCP_BEARER_TOKEN_CLIENT_ID.trim(), + scopes: parseCsvList(env.MCP_BEARER_TOKEN_SCOPES), + allowedTools: parseCsvList(env.MCP_BEARER_TOKEN_ALLOWED_TOOLS), + }) + } + + return principals.map(principal => ({ + ...principal, + scopes: principal.scopes.map(scope => scope.trim()).filter(Boolean), + allowedTools: principal.allowedTools?.map(tool => tool.trim()).filter(Boolean), + })) +} + +/** + * Validates and parses environment variables. + */ +export function validateEnvironment(): ParsedEnvironment { + try { + const parsed = rawEnvironmentSchema.parse(process.env) + const principals = parsePrincipals(parsed) + + if (parsed.MCP_TRANSPORT === 'http' && principals.length === 0) { + throw new Error('HTTP transport requires MCP_BEARER_TOKEN or MCP_BEARER_TOKENS_JSON') + } + + return { + MCP_TRANSPORT: parsed.MCP_TRANSPORT, + MCP_HOST: parsed.MCP_HOST, + PORT: parsed.PORT, + MCP_ALLOWED_HOSTS: parseCsvList(parsed.MCP_ALLOWED_HOSTS), + AUTH_PRINCIPALS: principals, + } + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map(entry => `${entry.path.join('.')}: ${entry.message}`) + .join('\n') + + throw new Error(`Environment validation failed:\n${errorMessages}`) + } + + throw error + } +} + +/** + * Gets validated environment configuration with memoization. + */ +export const getEnvironment = (() => { + let cachedEnvironment: ParsedEnvironment | undefined + + return (): ParsedEnvironment => { + if (cachedEnvironment === undefined) { + cachedEnvironment = validateEnvironment() + } + + return cachedEnvironment + } +})() diff --git a/tests/bearerAuth.test.ts b/tests/bearerAuth.test.ts new file mode 100644 index 0000000..0636cba --- /dev/null +++ b/tests/bearerAuth.test.ts @@ -0,0 +1,53 @@ +import express from 'express' +import request from 'supertest' +import { describe, expect, it } from 'vitest' +import { createBearerAuthMiddleware, getAllowedTools, type AuthenticatedRequest } from '../src/http/bearerAuth.js' + +function createTestApp() { + const app = express() + app.use(createBearerAuthMiddleware([{ token: 'secret-token', clientId: 'cowork', scopes: ['mcp'], allowedTools: ['check_credits'] }])) + app.get('/mcp', (req, res) => { + const authenticatedRequest = req as AuthenticatedRequest + + res.status(200).json({ + clientId: authenticatedRequest.auth?.clientId, + scopes: authenticatedRequest.auth?.scopes, + allowedTools: getAllowedTools(authenticatedRequest.auth), + }) + }) + + return app +} + +describe('Bearer Auth Middleware', () => { + it('rejects missing authorization headers', async () => { + const response = await request(createTestApp()).get('/mcp').set('Host', '127.0.0.1') + + expect(response.status).toBe(401) + expect(response.headers['www-authenticate']).toContain('Bearer realm="nutrient-dws-mcp-server"') + }) + + it('rejects invalid bearer tokens', async () => { + const response = await request(createTestApp()) + .get('/mcp') + .set('Host', '127.0.0.1') + .set('Authorization', 'Bearer wrong-token') + + expect(response.status).toBe(401) + expect(response.body.error).toBe('invalid_token') + }) + + it('accepts valid bearer tokens and attaches auth info', async () => { + const response = await request(createTestApp()) + .get('/mcp') + .set('Host', '127.0.0.1') + .set('Authorization', 'Bearer secret-token') + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + clientId: 'cowork', + scopes: ['mcp'], + allowedTools: ['check_credits'], + }) + }) +}) diff --git a/tests/build-api-examples.test.ts b/tests/build-api-examples.test.ts index a9c54c4..5674be9 100644 --- a/tests/build-api-examples.test.ts +++ b/tests/build-api-examples.test.ts @@ -8,7 +8,10 @@ import { setSandboxDirectory } from '../src/fs/sandbox.js' dotenvConfig() -describe('performBuildCall with build-api-examples', () => { +const liveExamplesEnabled = process.env.RUN_LIVE_DWS_EXAMPLE_TESTS === '1' +const describeLive = liveExamplesEnabled ? describe : describe.skip + +describeLive('performBuildCall with build-api-examples', () => { let outputDirectory: string beforeAll(async () => { const assetsDir = path.join(__dirname, `assets`) diff --git a/tests/environment.test.ts b/tests/environment.test.ts new file mode 100644 index 0000000..8c9a8b9 --- /dev/null +++ b/tests/environment.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Environment Validation', () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.env = originalEnv + vi.resetModules() + }) + + it('uses secure defaults for stdio transport', async () => { + process.env = {} + + const { validateEnvironment } = await import('../src/utils/environment.js') + const result = validateEnvironment() + + expect(result).toEqual({ + MCP_TRANSPORT: 'stdio', + MCP_HOST: '127.0.0.1', + PORT: 5100, + MCP_ALLOWED_HOSTS: [], + AUTH_PRINCIPALS: [], + }) + }) + + it('requires inbound auth in http mode', async () => { + process.env = { + MCP_TRANSPORT: 'http', + } + + const { validateEnvironment } = await import('../src/utils/environment.js') + + expect(() => validateEnvironment()).toThrow('HTTP transport requires MCP_BEARER_TOKEN or MCP_BEARER_TOKENS_JSON') + }) + + it('parses single-token bearer auth settings', async () => { + process.env = { + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + PORT: '8080', + MCP_ALLOWED_HOSTS: 'mcp.internal.example, connector.example.com ', + MCP_BEARER_TOKEN: 'secret-token', + MCP_BEARER_TOKEN_CLIENT_ID: 'cowork-prod', + MCP_BEARER_TOKEN_SCOPES: 'mcp,documents', + MCP_BEARER_TOKEN_ALLOWED_TOOLS: 'check_credits,sandbox_file_tree', + } + + const { validateEnvironment } = await import('../src/utils/environment.js') + const result = validateEnvironment() + + expect(result).toEqual({ + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + PORT: 8080, + MCP_ALLOWED_HOSTS: ['mcp.internal.example', 'connector.example.com'], + AUTH_PRINCIPALS: [ + { + token: 'secret-token', + clientId: 'cowork-prod', + scopes: ['mcp', 'documents'], + allowedTools: ['check_credits', 'sandbox_file_tree'], + }, + ], + }) + }) + + it('parses multi-principal json config', async () => { + process.env = { + MCP_TRANSPORT: 'http', + MCP_BEARER_TOKENS_JSON: JSON.stringify([ + { + token: 'token-a', + clientId: 'cowork-a', + scopes: ['mcp'], + allowedTools: ['check_credits'], + }, + { + token: 'token-b', + clientId: 'cowork-b', + scopes: ['mcp', 'documents'], + }, + ]), + } + + const { validateEnvironment } = await import('../src/utils/environment.js') + const result = validateEnvironment() + + expect(result.AUTH_PRINCIPALS).toEqual([ + { + token: 'token-a', + clientId: 'cowork-a', + scopes: ['mcp'], + allowedTools: ['check_credits'], + }, + { + token: 'token-b', + clientId: 'cowork-b', + scopes: ['mcp', 'documents'], + }, + ]) + }) + + it('caches validated environment', async () => { + process.env = { + MCP_BEARER_TOKEN: 'secret-token', + MCP_TRANSPORT: 'http', + } + + const { getEnvironment } = await import('../src/utils/environment.js') + const first = getEnvironment() + const second = getEnvironment() + + expect(first).toBe(second) + }) +}) diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts new file mode 100644 index 0000000..e6b4464 --- /dev/null +++ b/tests/httpTransport.test.ts @@ -0,0 +1,192 @@ +import request from 'supertest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createHttpApp, type HttpAppContext } from '../src/index.js' +import type { ParsedEnvironment } from '../src/utils/environment.js' + +const initializeRequest = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, +} + +function withDefaultHeaders(token?: string) { + const headers: Record = { + Host: '127.0.0.1', + Accept: 'application/json, text/event-stream', + } + + if (token) { + headers.Authorization = `Bearer ${token}` + } + + return headers +} + +describe('HTTP Transport', () => { + let httpContext: HttpAppContext + + beforeEach(() => { + const env: ParsedEnvironment = { + MCP_TRANSPORT: 'http', + MCP_HOST: '127.0.0.1', + PORT: 5100, + MCP_ALLOWED_HOSTS: [], + AUTH_PRINCIPALS: [ + { + token: 'token-a', + clientId: 'cowork-a', + scopes: ['mcp'], + allowedTools: ['check_credits'], + }, + { + token: 'token-b', + clientId: 'cowork-b', + scopes: ['mcp'], + allowedTools: ['document_processor'], + }, + ], + } + + httpContext = createHttpApp({ env, sandboxDir: null }) + }) + + afterEach(async () => { + await httpContext.close() + }) + + async function initializeSession(token: string) { + const response = await request(httpContext.app) + .post('/mcp') + .set(withDefaultHeaders(token)) + .send(initializeRequest) + + return { + response, + sessionId: response.headers['mcp-session-id'] as string | undefined, + } + } + + async function sendInitializedNotification(sessionId: string, token: string) { + return request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders(token), 'mcp-session-id': sessionId }) + .send({ + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {}, + }) + } + + it('serves health without authentication', async () => { + const response = await request(httpContext.app).get('/health').set('Host', '127.0.0.1') + + expect(response.status).toBe(200) + expect(response.body).toMatchObject({ + status: 'ok', + transport: 'http', + }) + }) + + it('initializes an authenticated session and filters tools by principal', async () => { + const { response, sessionId } = await initializeSession('token-a') + + expect(response.status).toBe(200) + expect(sessionId).toBeTruthy() + + await sendInitializedNotification(sessionId!, 'token-a') + + const toolsResponse = await request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': sessionId! }) + .send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }) + + expect(toolsResponse.status).toBe(200) + expect(toolsResponse.body.result.tools.map((tool: { name: string }) => tool.name)).toEqual(['check_credits']) + }) + + it('reuses sessions for subsequent requests from the same principal', async () => { + const { sessionId } = await initializeSession('token-a') + await sendInitializedNotification(sessionId!, 'token-a') + + const pingResponse = await request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': sessionId! }) + .send({ + jsonrpc: '2.0', + id: 2, + method: 'ping', + }) + + expect(pingResponse.status).toBe(200) + expect(pingResponse.body.result).toEqual({}) + }) + + it('rejects requests with unknown session ids', async () => { + const postResponse = await request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': 'missing-session' }) + .send({ + jsonrpc: '2.0', + id: 2, + method: 'ping', + }) + + const getResponse = await request(httpContext.app) + .get('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': 'missing-session' }) + + expect(postResponse.status).toBe(404) + expect(getResponse.status).toBe(404) + }) + + it('rejects session reuse by a different principal', async () => { + const { sessionId } = await initializeSession('token-a') + await sendInitializedNotification(sessionId!, 'token-a') + + const response = await request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders('token-b'), 'mcp-session-id': sessionId! }) + .send({ + jsonrpc: '2.0', + id: 2, + method: 'ping', + }) + + expect(response.status).toBe(403) + }) + + it('closes sessions through DELETE /mcp', async () => { + const { sessionId } = await initializeSession('token-a') + await sendInitializedNotification(sessionId!, 'token-a') + + const deleteResponse = await request(httpContext.app) + .delete('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': sessionId! }) + + expect(deleteResponse.status).toBe(200) + + const pingResponse = await request(httpContext.app) + .post('/mcp') + .set({ ...withDefaultHeaders('token-a'), 'mcp-session-id': sessionId! }) + .send({ + jsonrpc: '2.0', + id: 2, + method: 'ping', + }) + + expect(pingResponse.status).toBe(404) + }) +}) diff --git a/tests/signing-api-examples.test.ts b/tests/signing-api-examples.test.ts index 14d5cef..f1b833c 100644 --- a/tests/signing-api-examples.test.ts +++ b/tests/signing-api-examples.test.ts @@ -8,7 +8,10 @@ import { setSandboxDirectory } from '../src/fs/sandbox.js' dotenvConfig() -describe('performSignCall with signing-api-examples', () => { +const liveExamplesEnabled = process.env.RUN_LIVE_DWS_EXAMPLE_TESTS === '1' +const describeLive = liveExamplesEnabled ? describe : describe.skip + +describeLive('performSignCall with signing-api-examples', () => { let outputDirectory: string beforeAll(async () => { const assetsDir = path.join(__dirname, `assets`) From e36510e24a2acc0ea1faeb297cb58d6c743e3c1c Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Sat, 7 Mar 2026 01:34:36 -0500 Subject: [PATCH 2/3] fix(ci): remove broken pnpm cache setup Remove the setup-node pnpm cache configuration so GitHub Actions no longer fails before Corepack enables pnpm. --- .github/workflows/ci.yml | 1 - .github/workflows/container-publish.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b61a051..9d93472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,6 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 20 - cache: pnpm - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/container-publish.yml b/.github/workflows/container-publish.yml index 1114aa2..39c359c 100644 --- a/.github/workflows/container-publish.yml +++ b/.github/workflows/container-publish.yml @@ -33,7 +33,6 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 20 - cache: pnpm - name: Enable Corepack run: corepack enable From 3195bc09aa387d730e4e59164c17157d6bff3c95 Mon Sep 17 00:00:00 2001 From: "Jonathan D. Rhyne" Date: Sat, 7 Mar 2026 01:37:18 -0500 Subject: [PATCH 3/3] fix(ci): include linux native dependencies Add linux to pnpm supported architectures so CI installs Rollup's Linux native package for Vitest. --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df94eba..7c10c1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ supportedArchitectures: os: + - linux - win32 - darwin cpu: