feat: initial MCP server for printing markdown to Epson TM-m30
Single tool `print(markdown, title?, timestamp?, qr_urls?, cut?)`. Hand-rolled line-by-line markdown parser translates headings/bold/code/lists/blockquotes/ HR/links to ESC/POS, then sends raw bytes via TCP to 192.168.0.184:9100. Mirrors mcp-gemma4 layout (stdio server, single server.py, mcp package).
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
GITEA_API.md
|
||||
.backup/
|
||||
@@ -0,0 +1,28 @@
|
||||
# mcp-pos-print
|
||||
|
||||
> MCP server that prints markdown to the Epson TM-m30 receipt printer.
|
||||
|
||||
## Start Here
|
||||
|
||||
**Read the latest handoff first:** `.claude/handoffs/` (most recent file).
|
||||
It has session state, in-progress work, and ordered next steps.
|
||||
|
||||
Then check `IDEA.md` for the project brief and `DECISIONS.md` for settled choices.
|
||||
|
||||
## Project Identity
|
||||
|
||||
Stdio MCP server exposing one tool, `print`, that takes a markdown string (plus optional title, timestamp, and QR URL list) and sends ESC/POS bytes to the TM-m30 at `192.168.0.184:9100`. Mirrors the `mcp-gemma4` layout — single `server.py`, stdio transport, registered in `~/.claude.json`. The server owns markdown → ESC/POS translation; callers do not need to know any printer-specific schema.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** building
|
||||
- **Repo:** git.sethpc.xyz/Seth/mcp-pos-print (planned)
|
||||
- **Deploy target:** invoked locally by Claude Code on whichever host has LAN access to `192.168.0.184` (typically steel141)
|
||||
- **Status:** initial scaffold
|
||||
|
||||
## Conventions
|
||||
|
||||
- See `~/bin/POS_PRINT.md` for the printer reference (IP, port, Font B columns, native QR usage).
|
||||
- See `~/bin/mcp-gemma4/server.py` for the MCP layout pattern to mirror.
|
||||
- Env overrides: `POS_PRINTER_IP`, `POS_PRINTER_PORT`, `POS_PRINTER_TIMEOUT`.
|
||||
- Always Font B (`font='b'`, 57 cols) — Font A column math is wrong for 80mm paper at the default size.
|
||||
@@ -0,0 +1,26 @@
|
||||
# DECISIONS.md — mcp-pos-print Decision Log
|
||||
|
||||
Project-specific decisions. For global/cross-cutting decisions, see `~/bin/DECISIONS.md`.
|
||||
|
||||
Format: `YYYY-MM-DD: <decision> — <why>`
|
||||
|
||||
## Architecture
|
||||
|
||||
- 2026-05-25: stdio MCP server, single file (`server.py`), mirrors `mcp-gemma4` pattern — Seth already runs MCPs this way; no point inventing a new layout.
|
||||
- 2026-05-25: Single `print` tool taking markdown — Claude already speaks markdown fluently, no printer-specific schema to learn on the call side. Server owns the markdown→ESC/POS translation.
|
||||
- 2026-05-25: Hand-rolled line-by-line markdown parser, no `markdown-it-py` dependency — our subset (headings, bold, hr, lists, code blocks, links, blockquotes) is small enough that a dependency would be heavier than the parser.
|
||||
- 2026-05-25: Font B (57 cols) hard-coded — POS_PRINT.md mandates it for the TM-m30 80mm paper.
|
||||
|
||||
## Implementation
|
||||
|
||||
- 2026-05-25: `qr_urls` kwarg (list[str]) for QR codes at end of receipt — keeps the markdown body pure text and avoids inventing a custom QR syntax.
|
||||
- 2026-05-25: Env overrides `POS_PRINTER_IP` / `POS_PRINTER_PORT` / `POS_PRINTER_TIMEOUT` — match the conventions documented in `~/bin/POS_PRINT.md`.
|
||||
|
||||
## Deferred / Rejected
|
||||
<!-- Decisions NOT to do something are just as valuable -- prevents re-proposing rejected ideas -->
|
||||
|
||||
- 2026-05-25: **Rejected** image printing (base64 PIL) — no use case proposed; can add a `print_image` tool later if it actually comes up. YAGNI.
|
||||
- 2026-05-25: **Rejected** separate `print_qr` / `print_barcode` / `print_separator` primitive tools — would force Claude to make many tool calls per receipt (chatty, printer drains between calls). One tool + markdown + optional `qr_urls` covers the case.
|
||||
- 2026-05-25: **Rejected** drawer-kick / buzzer / multi-receipt batching — no drawer attached, no buzzer attached, no multi-receipt workflow exists.
|
||||
- 2026-05-25: **Rejected** auto-QR for any link in markdown body — surprising behavior, would produce a wall of QR codes for documents with many links. Explicit `qr_urls` only.
|
||||
- 2026-05-25: **Rejected** truncation of large markdown input — Claude is the one deciding what to print; if it sends a long document, print it. Paper is cheap.
|
||||
@@ -0,0 +1,16 @@
|
||||
# IDEA.md — mcp-pos-print
|
||||
|
||||
## What is this?
|
||||
|
||||
An MCP server that exposes the Epson TM-m30 receipt printer (192.168.0.184) as a single tool to any Claude Code session. Claude calls `print` with a markdown string, the server translates markdown → ESC/POS and sends raw bytes over TCP to the printer.
|
||||
|
||||
## Problem it solves
|
||||
|
||||
When working with Claude on the homelab box, useful output sometimes belongs on paper — a code snippet to keep at the desk, a quick summary, a TODO list, a QR for a URL. Currently this requires writing a one-off Python script that imports `escpos` and `socket`. With this MCP, Claude just says "let me print that" and it comes out of the printer with no extra plumbing.
|
||||
|
||||
## Constraints / preferences
|
||||
|
||||
- Python (matches existing `mcp-gemma4` pattern in this repo).
|
||||
- stdio transport (matches existing pattern; local-only invocation, no network exposure of the MCP itself).
|
||||
- Single tool, minimal surface — markdown in, paper out.
|
||||
- Mirrors conventions documented in `~/bin/POS_PRINT.md` (Font B, 57 cols, native QR, TCP to `192.168.0.184:9100`).
|
||||
@@ -0,0 +1,82 @@
|
||||
# mcp-pos-print
|
||||
|
||||
MCP server that prints markdown to the Epson TM-m30 receipt printer at `192.168.0.184:9100`. Mirrors the [`mcp-gemma4`](../mcp-gemma4/) layout.
|
||||
|
||||
See `~/bin/POS_PRINT.md` for the printer reference (Font B columns, native QR, troubleshooting) and `DECISIONS.md` for design rationale.
|
||||
|
||||
## Tool
|
||||
|
||||
`print(markdown, title?, timestamp?, qr_urls?, cut?)`
|
||||
|
||||
- **`markdown`** *(required)* — body to print. Supports `# / ## / ###` headings, `**bold**` and `` `code` `` inline, fenced ```` ``` ```` code blocks (no wrap), `---` / `***` horizontal rules, `-` / `*` / `1.` lists, `> blockquotes`, `[text](url)` links, blank lines. Long lines word-wrap at 57 cols.
|
||||
- **`title`** — bold centered header above the body.
|
||||
- **`timestamp`** — bool, default `true`. Prints `Mon, May 25 14:32 ET` under title.
|
||||
- **`qr_urls`** — list of URLs printed as native hardware QR codes at the end.
|
||||
- **`cut`** — bool, default `true`. Auto-cut paper.
|
||||
|
||||
Returns `Printed N bytes to 192.168.0.184:9100.` on success, or a clear error string if the printer is unreachable.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd ~/bin/mcp-pos-print
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
Register in `~/.claude.json` (global scope):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pos-print": {
|
||||
"command": "/home/claude/bin/mcp-pos-print/.venv/bin/python",
|
||||
"args": ["/home/claude/bin/mcp-pos-print/server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Code or run `/mcp` to reconnect.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
.venv/bin/python test_server.py
|
||||
```
|
||||
|
||||
Prints a self-test page covering headings, inline formatting, lists, code blocks, blockquotes, horizontal rules, and a QR code. Requires the printer reachable at `192.168.0.184:9100`.
|
||||
|
||||
## Config
|
||||
|
||||
- `POS_PRINTER_IP` — default `192.168.0.184`
|
||||
- `POS_PRINTER_PORT` — default `9100`
|
||||
- `POS_PRINTER_TIMEOUT` — TCP timeout in seconds, default `10`
|
||||
|
||||
## Markdown subset
|
||||
|
||||
Hand-rolled line-by-line parser, no markdown-it-py dependency. Supported:
|
||||
|
||||
| Markdown | Output |
|
||||
|---|---|
|
||||
| `# Heading` | bold + 2x size, centered |
|
||||
| `## Heading` / `### Heading` | bold, left |
|
||||
| `**bold**` inline | bold run |
|
||||
| `` `code` `` inline | passed through (already monospace) |
|
||||
| ```` ```fenced``` ```` | preserved verbatim, no wrap; `»` marks chars truncated past col 57 |
|
||||
| `---` / `***` / `___` | thin separator across full width |
|
||||
| `- item` / `* item` | `* item`, hanging-indented continuation |
|
||||
| `1. item` | `1. item`, hanging-indented continuation |
|
||||
| `> quote` | `\| ` prefix |
|
||||
| `[text](url)` | `text (url)` |
|
||||
| blank line | blank line |
|
||||
| anything else | word-wrapped at 57 cols |
|
||||
|
||||
Not supported (by design): images, tables, nested lists, footnotes, HTML. Use `qr_urls` for QR codes — there's no QR markdown syntax.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`cannot reach printer`** — power, network, paper, or ARP issue. `ping 192.168.0.184` first.
|
||||
- **`timed out`** — same. The TM-m30's WiFi sometimes drops ARP entries; a ping usually re-resolves.
|
||||
- **Garbled output** — most likely you mixed an external script that uses Font A; this server hard-codes Font B (57 cols). If you're seeing it from `print`, file a bug.
|
||||
- **No paper feed at end** — make sure `cut` isn't explicitly `false`.
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "mcp-pos-print"
|
||||
version = "0.1.0"
|
||||
description = "MCP server that prints markdown to the Epson TM-m30 receipt printer (192.168.0.184)."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.0.0",
|
||||
"python-escpos>=3.0",
|
||||
"qrcode>=7.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["server"]
|
||||
@@ -0,0 +1,403 @@
|
||||
"""MCP server exposing the Epson TM-m30 receipt printer as a `print` tool.
|
||||
|
||||
Takes markdown, translates it to ESC/POS, sends raw bytes over TCP to the
|
||||
printer at $POS_PRINTER_IP:$POS_PRINTER_PORT (default 192.168.0.184:9100).
|
||||
|
||||
See DECISIONS.md for design rationale and ~/bin/POS_PRINT.md for the printer
|
||||
reference (Font B columns, native QR, troubleshooting).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from escpos.printer import Dummy
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import TextContent, Tool
|
||||
|
||||
PRINTER_IP = os.environ.get("POS_PRINTER_IP", "192.168.0.184")
|
||||
PRINTER_PORT = int(os.environ.get("POS_PRINTER_PORT", "9100"))
|
||||
PRINTER_TIMEOUT = int(os.environ.get("POS_PRINTER_TIMEOUT", "10"))
|
||||
COLS = 57 # Font B on 80mm paper. See POS_PRINT.md.
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
server: Server = Server("pos-print")
|
||||
|
||||
TOOL_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"markdown": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The body to print, as markdown. Supports: # / ## / ### headings, "
|
||||
"**bold** and `code` inline, fenced ``` code blocks (no wrap), "
|
||||
"--- or *** horizontal rules, - / * / 1. lists, > blockquotes, "
|
||||
"[text](url) links, blank lines. Long lines word-wrap at 57 columns."
|
||||
),
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Optional bold centered header printed above the body.",
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Print a date/time line under the title (Eastern Time).",
|
||||
},
|
||||
"qr_urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of URLs to render as native hardware QR codes at the end.",
|
||||
},
|
||||
"cut": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Auto-cut the paper at the end. Almost always leave true.",
|
||||
},
|
||||
},
|
||||
"required": ["markdown"],
|
||||
}
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="print",
|
||||
description=(
|
||||
"Print arbitrary markdown to the Epson TM-m30 receipt printer. "
|
||||
"Use for code snippets, summaries, TODO lists, QR codes, or any "
|
||||
"output the user wants on paper. Returns confirmation with byte "
|
||||
"count, or a clear error if the printer is unreachable."
|
||||
),
|
||||
inputSchema=TOOL_SCHEMA,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# ── Inline markdown parser ───────────────────────────────────────────────────
|
||||
# Splits a line into [(style, text), ...] where style is one of:
|
||||
# "plain", "bold", "code"
|
||||
# Supports: **bold**, `code`, [text](url) -> "text (url)"
|
||||
|
||||
_INLINE_RE = re.compile(
|
||||
r"(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\n]+?\))"
|
||||
)
|
||||
|
||||
|
||||
def parse_inline(line: str) -> list[tuple[str, str]]:
|
||||
"""Parse a single line into styled segments. Order-preserving."""
|
||||
if not line:
|
||||
return [("plain", "")]
|
||||
out: list[tuple[str, str]] = []
|
||||
for part in _INLINE_RE.split(line):
|
||||
if not part:
|
||||
continue
|
||||
if part.startswith("**") and part.endswith("**"):
|
||||
out.append(("bold", part[2:-2]))
|
||||
elif part.startswith("`") and part.endswith("`"):
|
||||
out.append(("code", part[1:-1]))
|
||||
elif part.startswith("[") and "](" in part and part.endswith(")"):
|
||||
close = part.index("]")
|
||||
text = part[1:close]
|
||||
url = part[close + 2 : -1]
|
||||
out.append(("plain", f"{text} ({url})"))
|
||||
else:
|
||||
out.append(("plain", part))
|
||||
return out
|
||||
|
||||
|
||||
# ── Word wrap that respects styled segments ──────────────────────────────────
|
||||
def wrap_segments(
|
||||
segments: list[tuple[str, str]], width: int, indent: str = ""
|
||||
) -> list[list[tuple[str, str]]]:
|
||||
"""Wrap a styled line into multiple styled lines, never breaking mid-word
|
||||
where avoidable. First line uses no extra indent; continuation lines use
|
||||
`indent`. Returns a list of styled-segment lines."""
|
||||
lines: list[list[tuple[str, str]]] = [[]]
|
||||
col = 0
|
||||
first = True
|
||||
|
||||
def line_width() -> int:
|
||||
return width if first else max(1, width - len(indent))
|
||||
|
||||
for style, text in segments:
|
||||
# Split on whitespace but preserve the whitespace runs so we can rejoin.
|
||||
tokens = re.split(r"(\s+)", text)
|
||||
for tok in tokens:
|
||||
if not tok:
|
||||
continue
|
||||
if tok.isspace():
|
||||
# Whitespace: only add if not at start of line.
|
||||
if col > 0:
|
||||
lines[-1].append((style, " "))
|
||||
col += 1
|
||||
continue
|
||||
# Hard-break tokens longer than the line width by char.
|
||||
while len(tok) > line_width() - col and col > 0:
|
||||
lines.append([])
|
||||
col = 0
|
||||
first = False
|
||||
while len(tok) > line_width():
|
||||
head, tok = tok[: line_width()], tok[line_width() :]
|
||||
lines[-1].append((style, head))
|
||||
lines.append([])
|
||||
col = 0
|
||||
first = False
|
||||
lines[-1].append((style, tok))
|
||||
col += len(tok)
|
||||
|
||||
# Apply continuation indent
|
||||
if indent and len(lines) > 1:
|
||||
for i in range(1, len(lines)):
|
||||
lines[i] = [("plain", indent)] + lines[i]
|
||||
|
||||
# Drop trailing empty lines
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines or [[]]
|
||||
|
||||
|
||||
def emit_styled_line(p: Dummy, segs: list[tuple[str, str]]) -> None:
|
||||
"""Emit a single styled line (no trailing newline)."""
|
||||
for style, text in segs:
|
||||
if not text:
|
||||
continue
|
||||
if style == "bold":
|
||||
p.set(font="b", bold=True)
|
||||
p.text(text)
|
||||
p.set(font="b", bold=False)
|
||||
else:
|
||||
# 'code' and 'plain' both print as plain Font B; ESC/POS has no
|
||||
# monospace toggle (already monospace) and no background shading
|
||||
# worth using on a 57-col receipt.
|
||||
p.set(font="b", bold=False)
|
||||
p.text(text)
|
||||
|
||||
|
||||
# ── Block-level markdown parser ──────────────────────────────────────────────
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
_HR_RE = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
|
||||
_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$")
|
||||
_NUMBERED_RE = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
|
||||
_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
|
||||
_FENCE_RE = re.compile(r"^\s*```")
|
||||
|
||||
|
||||
def render_markdown(p: Dummy, md: str) -> None:
|
||||
"""Walk markdown line by line and emit ESC/POS via the Dummy builder."""
|
||||
lines = md.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Fenced code block: print verbatim, no wrap, until closing fence.
|
||||
if _FENCE_RE.match(line):
|
||||
i += 1
|
||||
while i < len(lines) and not _FENCE_RE.match(lines[i]):
|
||||
p.set(font="b", bold=False, align="left")
|
||||
# Truncate over-width code lines with a continuation marker.
|
||||
text = lines[i]
|
||||
while len(text) > COLS:
|
||||
p.text(text[: COLS - 1] + "»\n") # » continuation marker
|
||||
text = text[COLS - 1 :]
|
||||
p.text(text + "\n")
|
||||
i += 1
|
||||
i += 1 # consume the closing fence (or end of input)
|
||||
continue
|
||||
|
||||
# Horizontal rule
|
||||
if _HR_RE.match(line):
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("-" * COLS + "\n")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Headings
|
||||
m = _HEADING_RE.match(line)
|
||||
if m:
|
||||
level = len(m.group(1))
|
||||
text = m.group(2)
|
||||
if level == 1:
|
||||
p.set(font="b", bold=True, align="center", width=2, height=2)
|
||||
p.text(text + "\n")
|
||||
p.set(font="b", bold=False, align="left", width=1, height=1)
|
||||
elif level == 2:
|
||||
p.set(font="b", bold=True, align="left")
|
||||
p.text(text + "\n")
|
||||
p.set(font="b", bold=False, align="left")
|
||||
else:
|
||||
p.set(font="b", bold=True, align="left")
|
||||
p.text(text + "\n")
|
||||
p.set(font="b", bold=False, align="left")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Blockquote
|
||||
m = _QUOTE_RE.match(line)
|
||||
if m:
|
||||
inner = m.group(1)
|
||||
segs = parse_inline(inner)
|
||||
wrapped = wrap_segments(segs, COLS - 2, indent=" ")
|
||||
for wline in wrapped:
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("| ")
|
||||
emit_styled_line(p, wline)
|
||||
p.text("\n")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Bullet list item
|
||||
m = _BULLET_RE.match(line)
|
||||
if m:
|
||||
inner = m.group(1)
|
||||
segs = parse_inline(inner)
|
||||
wrapped = wrap_segments(segs, COLS - 2, indent=" ")
|
||||
for j, wline in enumerate(wrapped):
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("* " if j == 0 else "")
|
||||
emit_styled_line(p, wline)
|
||||
p.text("\n")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Numbered list item
|
||||
m = _NUMBERED_RE.match(line)
|
||||
if m:
|
||||
num = m.group(1)
|
||||
inner = m.group(2)
|
||||
prefix = f"{num}. "
|
||||
segs = parse_inline(inner)
|
||||
wrapped = wrap_segments(segs, COLS - len(prefix), indent=" " * len(prefix))
|
||||
for j, wline in enumerate(wrapped):
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text(prefix if j == 0 else "")
|
||||
emit_styled_line(p, wline)
|
||||
p.text("\n")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Blank line
|
||||
if not line.strip():
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("\n")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Plain paragraph line — wrap and emit with inline styles.
|
||||
segs = parse_inline(line)
|
||||
wrapped = wrap_segments(segs, COLS)
|
||||
for wline in wrapped:
|
||||
p.set(font="b", bold=False, align="left")
|
||||
emit_styled_line(p, wline)
|
||||
p.text("\n")
|
||||
i += 1
|
||||
|
||||
|
||||
# ── Receipt builder ──────────────────────────────────────────────────────────
|
||||
def build_receipt(
|
||||
markdown: str,
|
||||
title: str | None,
|
||||
timestamp: bool,
|
||||
qr_urls: list[str] | None,
|
||||
cut: bool,
|
||||
) -> bytes:
|
||||
p = Dummy(profile="default")
|
||||
|
||||
if title:
|
||||
p.set(font="b", bold=True, align="center", width=2, height=2)
|
||||
p.text(title + "\n")
|
||||
p.set(font="b", bold=False, align="center", width=1, height=1)
|
||||
if timestamp:
|
||||
p.set(font="b", bold=False, align="center")
|
||||
p.text(datetime.now(EASTERN).strftime("%a, %b %d %H:%M ET") + "\n")
|
||||
if title or timestamp:
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("=" * COLS + "\n")
|
||||
|
||||
render_markdown(p, markdown)
|
||||
|
||||
if qr_urls:
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("-" * COLS + "\n")
|
||||
for url in qr_urls:
|
||||
p.set(font="b", bold=False, align="center")
|
||||
p.qr(url, native=True, size=6)
|
||||
p.set(font="b", bold=False, align="center")
|
||||
p.text(url + "\n")
|
||||
p.text("\n")
|
||||
|
||||
if cut:
|
||||
p.set(font="b", bold=False, align="left")
|
||||
p.text("\n")
|
||||
p.cut()
|
||||
|
||||
return p.output
|
||||
|
||||
|
||||
def send_bytes(raw: bytes) -> None:
|
||||
"""Send raw ESC/POS bytes to the printer over TCP. Raises on failure."""
|
||||
with socket.create_connection(
|
||||
(PRINTER_IP, PRINTER_PORT), timeout=PRINTER_TIMEOUT
|
||||
) as sock:
|
||||
sock.sendall(raw)
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, args: dict[str, Any]) -> list[TextContent]:
|
||||
if name != "print":
|
||||
return [TextContent(type="text", text=f"Error: unknown tool `{name}`.")]
|
||||
|
||||
markdown = args.get("markdown")
|
||||
if not markdown or not str(markdown).strip():
|
||||
return [TextContent(type="text", text="Error: `markdown` is empty — nothing to print.")]
|
||||
|
||||
title = args.get("title")
|
||||
timestamp = bool(args.get("timestamp", True))
|
||||
qr_urls = args.get("qr_urls") or []
|
||||
cut = bool(args.get("cut", True))
|
||||
|
||||
def _build_and_send() -> tuple[int | None, str | None]:
|
||||
try:
|
||||
raw = build_receipt(str(markdown), title, timestamp, list(qr_urls), cut)
|
||||
except Exception as e:
|
||||
return None, f"Error: failed to build receipt bytes: {type(e).__name__}: {e}"
|
||||
try:
|
||||
send_bytes(raw)
|
||||
except (TimeoutError, socket.timeout):
|
||||
return (
|
||||
None,
|
||||
f"Error: printer at {PRINTER_IP}:{PRINTER_PORT} timed out after "
|
||||
f"{PRINTER_TIMEOUT}s. Likely off, paper out, or ARP issue — ping first.",
|
||||
)
|
||||
except OSError as e:
|
||||
return (
|
||||
None,
|
||||
f"Error: cannot reach printer at {PRINTER_IP}:{PRINTER_PORT} "
|
||||
f"({e}). Check power, network, paper.",
|
||||
)
|
||||
return len(raw), None
|
||||
|
||||
byte_count, err = await asyncio.to_thread(_build_and_send)
|
||||
if err:
|
||||
return [TextContent(type="text", text=err)]
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Printed {byte_count} bytes to {PRINTER_IP}:{PRINTER_PORT}.",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with stdio_server() as (read, write):
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Self-test for mcp-pos-print.
|
||||
|
||||
Runs the same code path as the MCP `print` tool (build_receipt + send_bytes)
|
||||
without spinning up an MCP server. Prints one self-documenting page covering
|
||||
every supported markdown feature plus a QR code.
|
||||
|
||||
Usage: .venv/bin/python test_server.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from server import build_receipt, send_bytes, PRINTER_IP, PRINTER_PORT
|
||||
|
||||
|
||||
SELF_TEST_MD = """\
|
||||
# mcp-pos-print
|
||||
|
||||
## Self test
|
||||
|
||||
This page exercises every supported markdown feature. If anything here looks
|
||||
broken on the printout, the parser has a bug.
|
||||
|
||||
### Inline formatting
|
||||
|
||||
Plain text. **Bold text**. `inline code`. A [link to example](https://example.com)
|
||||
that gets rendered as text + url in parens. Mixed: this **bold spans `inline
|
||||
code` and continues** after the code.
|
||||
|
||||
### Lists
|
||||
|
||||
- First bullet, short.
|
||||
- Second bullet that runs long enough to wrap onto a continuation line so we
|
||||
can verify the hanging indent works as expected at 57 columns.
|
||||
- Third bullet with **bold** and `code`.
|
||||
|
||||
1. Numbered one.
|
||||
2. Numbered two with a longer body that exercises hanging-indent for the
|
||||
numbered variant which has a different prefix width.
|
||||
3. Numbered three.
|
||||
|
||||
### Blockquote
|
||||
|
||||
> Single-line quote.
|
||||
|
||||
> Long quote that needs to wrap. The pipe prefix should appear on the first
|
||||
> line; continuation lines indent under the text, not under the pipe.
|
||||
|
||||
### Horizontal rule
|
||||
|
||||
---
|
||||
|
||||
### Code block
|
||||
|
||||
```
|
||||
def hello(name):
|
||||
print(f"hi, {name}")
|
||||
return 42
|
||||
```
|
||||
|
||||
### Long line
|
||||
|
||||
This is a single very long paragraph line that should soft-wrap at the 57
|
||||
column boundary determined by Font B on 80mm thermal paper, demonstrating
|
||||
that the wrap algorithm prefers word boundaries over hard breaks.
|
||||
|
||||
### End
|
||||
|
||||
If all of the above rendered correctly you are good to go.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = build_receipt(
|
||||
markdown=SELF_TEST_MD,
|
||||
title="mcp-pos-print",
|
||||
timestamp=True,
|
||||
qr_urls=["https://git.sethpc.xyz/Seth/mcp-pos-print"],
|
||||
cut=True,
|
||||
)
|
||||
print(f"Built {len(raw)} bytes. Sending to {PRINTER_IP}:{PRINTER_PORT}...")
|
||||
try:
|
||||
send_bytes(raw)
|
||||
except Exception as e:
|
||||
print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print("OK — check the printer.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user