@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 +21 -0
- package/README.md +107 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/confirmation/store.js +47 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/printing/posprintClient.js +25 -0
- package/dist/src/server.js +51 -0
- package/dist/src/tools/printReceipt.js +81 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validation/printReceiptSchema.js +26 -0
- package/package.json +50 -0
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`
|
package/dist/src/cli.js
ADDED
|
@@ -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
|
+
}
|