#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { parseArgs } from "node:util";
import { PaperlessAPI } from "./api/PaperlessAPI";
import { registerCorrespondentTools } from "./tools/correspondents";
import { registerCustomFieldTools } from "./tools/customFields";
import { registerDocumentTools } from "./tools/documents";
import { registerDocumentTypeTools } from "./tools/documentTypes";
import { registerTagTools } from "./tools/tags";
const {
values: { baseUrl, token, http: useHttp, port, publicUrl },
} = parseArgs({
options: {
baseUrl: { type: "string" },
token: { type: "string" },
http: { type: "boolean", default: false },
port: { type: "string" },
publicUrl: { type: "string", default: "" },
},
allowPositionals: true,
});
const resolvedBaseUrl = baseUrl || process.env.PAPERLESS_URL;
const resolvedToken = token || process.env.PAPERLESS_API_KEY;
const resolvedPublicUrl =
publicUrl || process.env.PAPERLESS_PUBLIC_URL || resolvedBaseUrl;
const resolvedPort = port ? parseInt(port, 10) : 3000;
if (!resolvedBaseUrl || !resolvedToken) {
console.error(
"Usage: paperless-mcp --baseUrl <url> --token <token> [--http] [--port <port>] [--publicUrl <url>]"
);
console.error(
"Or set PAPERLESS_URL and PAPERLESS_API_KEY environment variables."
);
process.exit(1);
}
async function main() {
// Initialize API client and server once
const api = new PaperlessAPI(resolvedBaseUrl!, resolvedToken!);
const server = new McpServer(
{ name: "paperless-ngx", version: "1.0.0" },
{
instructions: `
Paperless-NGX MCP Server Instructions
⚠️ CRITICAL: Always differentiate between operations on specific documents vs operations on the entire system:
- REMOVE operations (e.g., remove_tag in bulk_edit_documents): Affect only the specified documents, items remain in the system
- DELETE operations (e.g., delete_tag, delete_correspondent): Permanently delete items from the entire system, affecting ALL documents that use them
When a user asks to "remove" something, prefer operations that affect specific documents. Only use DELETE operations when explicitly asked to delete from the system.
To view documents in your Paperless-NGX web interface, construct URLs using this pattern:
${resolvedPublicUrl}/documents/{document_id}/
Example: If your base URL is "http://localhost:8000", the web interface URL would be "http://localhost:8000/documents/123/" for document ID 123.
The document tools return JSON data with document IDs that you can use to construct these URLs.
`,
}
);
registerDocumentTools(server, api);
registerTagTools(server, api);
registerCorrespondentTools(server, api);
registerDocumentTypeTools(server, api);
registerCustomFieldTools(server, api);
if (useHttp) {
const app = express();
app.use(express.json());
// Store transports for each session
const sseTransports: Record<string, SSEServerTransport> = {};
app.post("/mcp", async (req, res) => {
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
}
}
});
app.get("/mcp", async (req, res) => {
res.writeHead(405).end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Method not allowed.",
},
id: null,
})
);
});
app.delete("/mcp", async (req, res) => {
res.writeHead(405).end(
JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Method not allowed.",
},
id: null,
})
);
});
app.get("/sse", async (req, res) => {
console.log("SSE request received");
try {
const transport = new SSEServerTransport("/messages", res);
sseTransports[transport.sessionId] = transport;
res.on("close", () => {
delete sseTransports[transport.sessionId];
transport.close();
});
await server.connect(transport);
} catch (error) {
console.error("Error handling SSE request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
}
}
});
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = sseTransports[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send("No transport found for sessionId");
}
});
app.listen(resolvedPort, () => {
console.log(
`MCP Stateless Streamable HTTP Server listening on port ${resolvedPort}`
);
});
// await new Promise((resolve) => setTimeout(resolve, 1000000));
} else {
const transport = new StdioServerTransport();
await server.connect(transport);
}
}
main().catch((e) => console.error(e.message));