Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
878c106
feat: add E2E test suite against real Paperless-ngx in CI
claude May 20, 2026
986442a
fix: repair E2E CI failures and address CodeRabbit review
claude May 20, 2026
b07df38
fix: correct tasks API response parsing and update URI assertions
claude May 20, 2026
71d30c1
fix: exclude e2e from tsc to restore build/index.js output path
claude May 20, 2026
0db608c
fix: handle both paginated and array formats in waitForDocument
claude May 20, 2026
2a0abf7
fix(e2e): fix download test, thumbnail assertion, PDF xref offsets, p…
claude May 20, 2026
efea0b2
fix(e2e): add post-seed delay and capture test output on failure
claude May 20, 2026
643edbe
fix(e2e): poll search index instead of fixed delay, add diagnostics
claude May 20, 2026
3d7746a
fix(e2e): include error text in bulk_edit assertion message
claude May 20, 2026
f049fd7
fix(e2e): include tool error text in download/thumbnail assertions
claude May 20, 2026
c542428
fix(e2e): print actual before() hook error to stderr
claude May 20, 2026
e44cadd
fix(e2e): replace Whoosh-based wait with direct doc fetch + retry in …
claude May 20, 2026
eeff3ea
fix(bulk-edit): ensure add_tags and remove_tags default to [] for mod…
claude May 20, 2026
0e84dd3
Merge branch 'main' into claude/tender-wozniak-lYl1P
baruchiro May 24, 2026
3783285
refactor(e2e): drive scenario via MCP tools, drop separate Paperless …
claude May 24, 2026
fe3cf16
ci(e2e): migrate Paperless fixture from docker-compose to GitHub Acti…
claude May 24, 2026
06d30e1
ci(e2e): drop matrix, run CLI and Docker MCP modes sequentially in on…
claude May 24, 2026
8412a89
fix(e2e): include per-run title in PDF bytes so checksums differ
claude May 24, 2026
02d1278
ci(e2e): track paperless-ngx :latest and run weekly
claude May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-e2e-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@baruchiro/paperless-mcp": minor
---

Add E2E test suite that runs the compiled MCP server against a real Paperless-ngx instance in CI. Covers list/create for tags, correspondents, document types, list/get/search/download/thumbnail for documents, bulk_edit_documents, and post_document — all with deterministic tool calls and no LLM in the loop.
163 changes: 163 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
name: E2E Tests

on:
push:
branches:
- main
pull_request:
schedule:
# Weekly run to catch drift against upstream paperless-ngx :latest.
- cron: "0 8 * * 1"

jobs:
e2e:
runs-on: ubuntu-latest

services:
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10

paperless:
# Track upstream stable. Paperless-ngx publishes the latest stable
# release under :latest; the weekly schedule catches drift.
image: ghcr.io/paperless-ngx/paperless-ngx:latest
env:
PAPERLESS_REDIS: redis://redis:6379
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin123
PAPERLESS_ADMIN_MAIL: admin@test.local
PAPERLESS_SECRET_KEY: e2e-test-secret-not-for-production
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_OCR_MODE: skip
PAPERLESS_TIME_ZONE: UTC
ports:
- 8000:8000
options: >-
--health-cmd "curl -fsS http://localhost:8000/api/ || exit 1"
--health-interval 10s
--health-timeout 10s
--health-retries 30
--health-start-period 60s

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".node-version"

- name: Install dependencies
run: npm ci

- name: Build MCP server
run: npm run build

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker image for E2E
uses: docker/build-push-action@v6
Comment thread
baruchiro marked this conversation as resolved.
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: false
load: true
tags: paperless-mcp:e2e
cache-from: type=gha,scope=paperless-mcp-docker
cache-to: type=gha,scope=paperless-mcp-docker,mode=max

- name: Get Paperless API token
run: |
TOKEN=$(curl -s -X POST http://localhost:8000/api/token/ \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "Failed to get API token"
exit 1
fi
echo "PAPERLESS_TOKEN=$TOKEN" >> $GITHUB_ENV
echo "Got token: ${TOKEN:0:8}..."

# ---- CLI MCP mode ----

- name: Start MCP server (CLI mode)
run: |
node build/index.js \
--http --port 3001 \
--baseUrl http://localhost:8000 \
--token "$PAPERLESS_TOKEN" &
echo "MCP_CLI_PID=$!" >> $GITHUB_ENV
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/mcp || true)
if [ "$code" = "405" ]; then
echo "MCP server (CLI) ready"
exit 0
fi
sleep 1
done
echo "MCP server (CLI) failed to start"
exit 1

- name: Run E2E tests (CLI MCP)
run: node --require ts-node/register --test e2e/e2e.test.ts 2>&1 | tee /tmp/e2e-cli-output.txt; exit "${PIPESTATUS[0]}"
env:
PAPERLESS_URL: http://localhost:8000
PAPERLESS_TOKEN: ${{ env.PAPERLESS_TOKEN }}
MCP_URL: http://localhost:3001/mcp

- name: Stop CLI MCP
if: always()
run: kill "$MCP_CLI_PID" 2>/dev/null || true

# ---- Docker MCP mode ----

- name: Start MCP server (Docker mode)
if: ${{ !cancelled() }}
run: |
docker run -d --name paperless-mcp-e2e \
--network host \
-e PAPERLESS_URL=http://localhost:8000 \
-e PAPERLESS_API_KEY="$PAPERLESS_TOKEN" \
paperless-mcp:e2e
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/mcp || true)
if [ "$code" = "405" ]; then
echo "MCP server (Docker) ready"
exit 0
fi
sleep 1
done
echo "MCP server (Docker) failed to start"
docker logs paperless-mcp-e2e
exit 1

- name: Run E2E tests (Docker MCP)
if: ${{ !cancelled() }}
run: node --require ts-node/register --test e2e/e2e.test.ts 2>&1 | tee /tmp/e2e-docker-output.txt; exit "${PIPESTATUS[0]}"
env:
PAPERLESS_URL: http://localhost:8000
PAPERLESS_TOKEN: ${{ env.PAPERLESS_TOKEN }}
MCP_URL: http://localhost:3000/mcp

- name: Print logs on failure
if: failure()
run: |
echo "=== E2E CLI test output ==="
cat /tmp/e2e-cli-output.txt || true
echo "=== E2E Docker test output ==="
cat /tmp/e2e-docker-output.txt || true
echo "=== MCP Docker logs ==="
docker logs paperless-mcp-e2e --tail=50 || true

- name: Cleanup
if: always()
run: |
kill "$MCP_CLI_PID" 2>/dev/null || true
docker rm -f paperless-mcp-e2e 2>/dev/null || true
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,52 @@ The server will show clear error messages if:
- The requested operation fails
- The provided parameters are invalid

## Testing

### Unit tests

Run the unit test suite (no external dependencies required):

```bash
npm test
```

### E2E tests

The E2E suite boots an empty Paperless-ngx instance, runs the compiled MCP server, and drives a deterministic serial scenario through `tools/call` requests — creating a tag, correspondent, and document type, uploading a PDF, then exercising list / get / search / download / thumbnail / bulk-edit on the same document. No LLM and no Paperless REST client outside MCP.

**Prerequisites:** Docker, Docker Compose, and `jq`.

```bash
# 1. Build the MCP server
npm run build

# 2. Start Paperless-ngx
docker compose -f docker-compose.e2e.yml up -d

# 3. Wait for Paperless to be ready, then get a token
TOKEN=$(curl -s -X POST http://localhost:8000/api/token/ \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')

# 4. Start the MCP server
node build/index.js --http --port 3001 \
--baseUrl http://localhost:8000 --token "$TOKEN" &
MCP_PID=$!

# 5. Run the E2E tests
MCP_URL=http://localhost:3001/mcp \
PAPERLESS_URL=http://localhost:8000 \
PAPERLESS_TOKEN="$TOKEN" \
npm run test:e2e

# 6. Cleanup
kill "$MCP_PID"
docker compose -f docker-compose.e2e.yml down -v
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

E2E tests also run automatically in CI on every pull request and push to `main`, covering both the `build/index.js` CLI and the published Docker image.

## Development

Want to contribute or modify the server? Here's what you need to know:
Expand Down
34 changes: 34 additions & 0 deletions docker-compose.e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
services:
redis:
image: redis:7-alpine
restart: on-failure

paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7
restart: on-failure
depends_on:
- redis
ports:
- "8000:8000"
volumes:
- paperless-data:/usr/src/paperless/data
- paperless-media:/usr/src/paperless/media
environment:
PAPERLESS_REDIS: redis://redis:6379
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin123
PAPERLESS_ADMIN_MAIL: admin@test.local
PAPERLESS_SECRET_KEY: e2e-test-secret-not-for-production
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_OCR_MODE: skip
PAPERLESS_TIME_ZONE: UTC
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/"]
interval: 10s
timeout: 10s
retries: 30
start_period: 60s

volumes:
paperless-data:
paperless-media:
33 changes: 33 additions & 0 deletions e2e/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

export type ToolResult = {
content: Array<{ type: string; text?: string; resource?: unknown }>;
isError?: boolean;
};

export async function connectMcpClient(
mcpUrl: string,
token: string
): Promise<Client> {
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: {
headers: { Authorization: `Bearer ${token}` },
},
});
const client = new Client({ name: "e2e-test", version: "1.0.0" }, {});
await client.connect(transport);
return client;
}

export function parseToolText(result: ToolResult): unknown {
if (result.isError) {
const errContent = result.content.find((c) => c.type === "text");
throw new Error(`Tool call returned isError=true: ${errContent?.text ?? "(no message)"}`);
}
const textContent = result.content.find((c) => c.type === "text");
if (!textContent || !textContent.text) {
throw new Error("No text content in tool result");
}
return JSON.parse(textContent.text);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading