Files
mcp-pos-print/server.py
T
Mortdecai 421c3c9660 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).
2026-05-25 16:51:17 -04:00

404 lines
14 KiB
Python

"""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())