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:
Mortdecai
2026-05-25 16:51:17 -04:00
commit 421c3c9660
8 changed files with 665 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
GITEA_API.md
.backup/
+28
View File
@@ -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.
+26
View File
@@ -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.
+16
View File
@@ -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`).
+82
View File
@@ -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`.
+13
View File
@@ -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"]
+403
View File
@@ -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())
+91
View File
@@ -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())