@bestimmaa/posprint-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Bestimmaa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @bestimmaa/posprint-mcp
2
+
3
+ MCP server for POS printer receipts using [`@bestimmaa/posprint`](https://www.npmjs.com/package/@bestimmaa/posprint).
4
+
5
+ The tool is intentionally named `print` so clients can map natural user phrasing such as "print receipt", "hard copy", or "print this out" to the same operation.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 20+
10
+ - A printer reachable via a CUPS URI supported by `@bestimmaa/posprint`
11
+
12
+ ## Run With npx
13
+
14
+ ```bash
15
+ npx @bestimmaa/posprint-mcp
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -g @bestimmaa/posprint-mcp
22
+ ```
23
+
24
+ After global installation, the package exposes a `posprint-mcp` binary on your PATH.
25
+
26
+ ## MCP Client Configuration
27
+
28
+ Use `posprint-mcp` as the command when the package is installed in the client environment.
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "posprint": {
34
+ "command": "posprint-mcp"
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ For one-off use without installation, configure the command through `npx`.
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "posprint": {
46
+ "command": "npx",
47
+ "args": ["--yes", "@bestimmaa/posprint-mcp"]
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ npm install
57
+ npm run build
58
+ npm test
59
+ ```
60
+
61
+ Run the local server from source:
62
+
63
+ ```bash
64
+ npm run dev
65
+ ```
66
+
67
+ Run the built server:
68
+
69
+ ```bash
70
+ npm start
71
+ ```
72
+
73
+ ## Tool: `print`
74
+
75
+ Input:
76
+
77
+ - `printerUri: string`
78
+ - `markdown: string`
79
+ - `mode: "preview" | "confirm"`
80
+ - `confirmationToken?: string` (required when `mode="confirm"`)
81
+ - `options?: { copies?: number; timeoutMs?: number }`
82
+
83
+ ### Two-step confirmation flow
84
+
85
+ 1. Call `print` with `mode: "preview"`.
86
+ 2. Show the returned snippet to the user and ask for confirmation.
87
+ 3. Call `print` again with `mode: "confirm"` and the returned `confirmationToken`.
88
+
89
+ Preview response includes:
90
+
91
+ - `requiresConfirmation: true`
92
+ - `confirmationToken`
93
+ - `preview.lineCount`
94
+ - `preview.snippet`
95
+ - `preview.excessiveLengthWarning` (present when markdown is more than 80 lines)
96
+
97
+ Confirm response shape:
98
+
99
+ ```json
100
+ { "ok": true, "meta": { "printerUri": "...", "durationMs": 20, "printedAt": "...", "jobId": "optional" } }
101
+ ```
102
+
103
+ Error codes:
104
+
105
+ - `VALIDATION_ERROR`
106
+ - `PRINTER_ERROR`
107
+ - `TIMEOUT`
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { startServer } from "./server.js";
3
+ await startServer();
@@ -0,0 +1,47 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { AppError } from "../errors.js";
3
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
4
+ const confirmationStore = new Map();
5
+ function optionsKey(options) {
6
+ return JSON.stringify({
7
+ copies: options?.copies ?? null,
8
+ timeoutMs: options?.timeoutMs ?? null
9
+ });
10
+ }
11
+ export function buildMarkdownHash(markdown) {
12
+ return createHash("sha256").update(markdown, "utf8").digest("hex");
13
+ }
14
+ export function createConfirmationToken(input) {
15
+ const token = randomUUID();
16
+ confirmationStore.set(token, {
17
+ printerUri: input.printerUri,
18
+ markdownHash: buildMarkdownHash(input.markdown),
19
+ optionsKey: optionsKey(input.options),
20
+ createdAt: Date.now()
21
+ });
22
+ return token;
23
+ }
24
+ export function consumeConfirmationToken(input, now = Date.now(), ttlMs = DEFAULT_TTL_MS) {
25
+ const pending = confirmationStore.get(input.confirmationToken);
26
+ if (!pending) {
27
+ throw new AppError("VALIDATION_ERROR", "Invalid confirmation token", {
28
+ confirmationToken: input.confirmationToken
29
+ });
30
+ }
31
+ if (now - pending.createdAt > ttlMs) {
32
+ confirmationStore.delete(input.confirmationToken);
33
+ throw new AppError("VALIDATION_ERROR", "Confirmation token expired", {
34
+ confirmationToken: input.confirmationToken
35
+ });
36
+ }
37
+ const incomingHash = buildMarkdownHash(input.markdown);
38
+ if (pending.printerUri !== input.printerUri || pending.markdownHash !== incomingHash || pending.optionsKey !== optionsKey(input.options)) {
39
+ throw new AppError("VALIDATION_ERROR", "Confirmation token does not match print request", {
40
+ confirmationToken: input.confirmationToken
41
+ });
42
+ }
43
+ confirmationStore.delete(input.confirmationToken);
44
+ }
45
+ export function clearConfirmationStoreForTests() {
46
+ confirmationStore.clear();
47
+ }
@@ -0,0 +1,21 @@
1
+ export class AppError extends Error {
2
+ code;
3
+ meta;
4
+ constructor(code, message, meta) {
5
+ super(message);
6
+ this.code = code;
7
+ this.meta = meta;
8
+ this.name = "AppError";
9
+ }
10
+ }
11
+ export function mapUnknownError(error) {
12
+ if (error instanceof AppError) {
13
+ return error;
14
+ }
15
+ if (error instanceof Error && /timeout/i.test(error.message)) {
16
+ return new AppError("TIMEOUT", "Printing timed out", { cause: error.message });
17
+ }
18
+ return new AppError("UNKNOWN_ERROR", "Unexpected printing failure", {
19
+ cause: error instanceof Error ? error.message : String(error)
20
+ });
21
+ }
@@ -0,0 +1,25 @@
1
+ import posprint from "@bestimmaa/posprint";
2
+ const { markdownToEscpos, printRawToPrinterUri } = posprint;
3
+ function withTimeout(promise, timeoutMs) {
4
+ return new Promise((resolve, reject) => {
5
+ const timer = setTimeout(() => reject(new Error(`print timeout after ${timeoutMs}ms`)), timeoutMs);
6
+ promise
7
+ .then((value) => {
8
+ clearTimeout(timer);
9
+ resolve(value);
10
+ })
11
+ .catch((error) => {
12
+ clearTimeout(timer);
13
+ reject(error);
14
+ });
15
+ });
16
+ }
17
+ export async function printMarkdown(params) {
18
+ const { printerUri, markdown, copies = 1, timeoutMs = 15_000, charsPerLine = 42 } = params;
19
+ const escpos = markdownToEscpos(markdown, { charsPerLine });
20
+ const payload = Buffer.from(escpos);
21
+ for (let i = 0; i < copies; i += 1) {
22
+ await withTimeout(printRawToPrinterUri(printerUri, payload), timeoutMs);
23
+ }
24
+ return {};
25
+ }
@@ -0,0 +1,51 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { AppError } from "./errors.js";
5
+ import { handlePrintReceipt } from "./tools/printReceipt.js";
6
+ export function createServer() {
7
+ const server = new McpServer({
8
+ name: "posprint-mcp",
9
+ version: "0.1.0"
10
+ });
11
+ server.tool("print", "Print markdown content to a POS printer via CUPS URI. Use this when the user asks to print, print a receipt, make a hard copy, or print something out. Call with mode=preview first, then call again with mode=confirm and confirmationToken.", {
12
+ printerUri: z.string(),
13
+ markdown: z.string(),
14
+ mode: z.enum(["preview", "confirm"]),
15
+ confirmationToken: z.string().optional(),
16
+ options: z
17
+ .object({
18
+ copies: z.number().int().positive().optional(),
19
+ timeoutMs: z.number().int().positive().optional()
20
+ })
21
+ .optional()
22
+ }, async (args) => {
23
+ try {
24
+ const result = await handlePrintReceipt(args);
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text",
29
+ text: JSON.stringify(result)
30
+ }
31
+ ]
32
+ };
33
+ }
34
+ catch (error) {
35
+ if (error instanceof AppError) {
36
+ throw new Error(JSON.stringify({
37
+ code: error.code,
38
+ message: error.message,
39
+ meta: error.meta
40
+ }));
41
+ }
42
+ throw error;
43
+ }
44
+ });
45
+ return server;
46
+ }
47
+ export async function startServer() {
48
+ const server = createServer();
49
+ const transport = new StdioServerTransport();
50
+ await server.connect(transport);
51
+ }
@@ -0,0 +1,81 @@
1
+ import { ZodError } from "zod";
2
+ import { createConfirmationToken, consumeConfirmationToken } from "../confirmation/store.js";
3
+ import { AppError, mapUnknownError } from "../errors.js";
4
+ import { printMarkdown } from "../printing/posprintClient.js";
5
+ import { parsePrintReceiptInput } from "../validation/printReceiptSchema.js";
6
+ const EXCESSIVE_LINE_THRESHOLD = 80;
7
+ const PREVIEW_LINE_COUNT = 20;
8
+ function getLineCount(markdown) {
9
+ return markdown.split(/\r?\n/).length;
10
+ }
11
+ function buildSnippet(markdown, lineCount = PREVIEW_LINE_COUNT) {
12
+ return markdown.split(/\r?\n/).slice(0, lineCount).join("\n");
13
+ }
14
+ export async function handlePrintReceipt(input) {
15
+ const start = Date.now();
16
+ try {
17
+ const parsed = parsePrintReceiptInput(input);
18
+ if (parsed.mode === "preview") {
19
+ const lineCount = getLineCount(parsed.markdown);
20
+ const confirmationToken = createConfirmationToken({
21
+ printerUri: parsed.printerUri,
22
+ markdown: parsed.markdown,
23
+ options: parsed.options
24
+ });
25
+ return {
26
+ ok: true,
27
+ requiresConfirmation: true,
28
+ confirmationToken,
29
+ preview: {
30
+ lineCount,
31
+ snippet: buildSnippet(parsed.markdown),
32
+ suggestedAction: "Ask the user to confirm printing this preview or summarize it first.",
33
+ ...(lineCount > EXCESSIVE_LINE_THRESHOLD
34
+ ? {
35
+ excessiveLengthWarning: "This printout is long (>80 lines). Consider summarizing before printing."
36
+ }
37
+ : {})
38
+ }
39
+ };
40
+ }
41
+ consumeConfirmationToken({
42
+ confirmationToken: parsed.confirmationToken,
43
+ printerUri: parsed.printerUri,
44
+ markdown: parsed.markdown,
45
+ options: parsed.options
46
+ });
47
+ const result = await printMarkdown({
48
+ printerUri: parsed.printerUri,
49
+ markdown: parsed.markdown,
50
+ copies: parsed.options?.copies,
51
+ timeoutMs: parsed.options?.timeoutMs
52
+ });
53
+ return {
54
+ ok: true,
55
+ meta: {
56
+ printerUri: parsed.printerUri,
57
+ durationMs: Date.now() - start,
58
+ printedAt: new Date().toISOString(),
59
+ ...(result.jobId ? { jobId: result.jobId } : {})
60
+ }
61
+ };
62
+ }
63
+ catch (error) {
64
+ if (error instanceof ZodError) {
65
+ throw new AppError("VALIDATION_ERROR", "Invalid print input", {
66
+ issues: error.issues
67
+ });
68
+ }
69
+ const mapped = mapUnknownError(error);
70
+ if (mapped.code === "UNKNOWN_ERROR") {
71
+ throw new AppError("PRINTER_ERROR", "Printer job failed", {
72
+ ...mapped.meta,
73
+ durationMs: Date.now() - start
74
+ });
75
+ }
76
+ throw new AppError(mapped.code, mapped.message, {
77
+ ...mapped.meta,
78
+ durationMs: Date.now() - start
79
+ });
80
+ }
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ const printReceiptSchema = z
3
+ .object({
4
+ printerUri: z.string().trim().url("printerUri must be a valid URI"),
5
+ markdown: z.string().min(1, "markdown cannot be empty"),
6
+ mode: z.enum(["preview", "confirm"]),
7
+ confirmationToken: z.string().min(1, "confirmationToken cannot be empty").optional(),
8
+ options: z
9
+ .object({
10
+ copies: z.number().int().positive().max(20).optional(),
11
+ timeoutMs: z.number().int().positive().max(120_000).optional()
12
+ })
13
+ .optional()
14
+ })
15
+ .superRefine((value, context) => {
16
+ if (value.mode === "confirm" && !value.confirmationToken) {
17
+ context.addIssue({
18
+ code: z.ZodIssueCode.custom,
19
+ message: "confirmationToken is required when mode is confirm",
20
+ path: ["confirmationToken"]
21
+ });
22
+ }
23
+ });
24
+ export function parsePrintReceiptInput(input) {
25
+ return printReceiptSchema.parse(input);
26
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@bestimmaa/posprint-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for printing markdown receipts on POS printers.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "main": "dist/src/server.js",
11
+ "bin": {
12
+ "posprint-mcp": "./dist/src/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bestimmaa/posprint-mcp.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/bestimmaa/posprint-mcp/issues"
25
+ },
26
+ "homepage": "https://github.com/bestimmaa/posprint-mcp#readme",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
32
+ "prepack": "npm run build",
33
+ "start": "node dist/src/cli.js",
34
+ "dev": "tsx src/cli.ts",
35
+ "test": "vitest run --passWithNoTests",
36
+ "test:watch": "vitest",
37
+ "release": "node scripts/release.js"
38
+ },
39
+ "dependencies": {
40
+ "@bestimmaa/posprint": "^0.2.0",
41
+ "@modelcontextprotocol/sdk": "^1.13.0",
42
+ "zod": "^3.23.8"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.15.3",
46
+ "tsx": "^4.19.3",
47
+ "typescript": "^5.8.3",
48
+ "vitest": "^2.1.8"
49
+ }
50
+ }