| Status | Implemented in v0.1.0 — see Implementation notes |
| Author | guangfei.me@gmail.com |
| Created | 2026-08-22 |
| Repository | deckflow/deckprobe-mcp-server (standalone; engine consumed from npm) |
| Proposed package | @deckflow/deckprobe-mcp · bin deckprobe-mcp |
| Upstream project | deckflow/deckprobe |
DeckProbe today ships a native CLI, an npm package (CLI + browser/Node WASM SDK), and an Agent Skill. This RFC adds the fourth integration surface: a Model Context Protocol (MCP) server in Node.js + TypeScript, so any MCP client (Claude Code, Claude Desktop, Cursor, VS Code, Zed, custom agents) can probe PDF, Microsoft Office, and Apple iWork documents through typed tool calls instead of shell access.
The server is a thin, faithful adapter over the existing CLI contract:
- 4 tools —
probe,probe_batch,list_formats,list_targets— that mirror the CLI's target vocabulary and return the unmodified schema-v2 JSON report. - 1 resource — the bundled report JSON Schema.
- stdio transport, distributed on npm, runnable with a single
npx -y @deckflow/deckprobe-mcp.
It adds no new probing semantics. Everything the report means — confidence
labels, partial status, target statuses, budgets, exit codes — is defined
upstream and passed through verbatim.
- The Agent Skill works well for agents that have a shell, but many MCP clients (Claude Desktop, IDE chat panes, hosted agents) have no shell tool or restrict it. MCP is the standard way to reach those.
- Typed tool schemas remove the two failure modes the skill documents: flag guessing and hand-rolled document unzipping. The MCP layer validates arguments before the engine ever runs.
- A first-party server keeps the tool surface aligned with the report contract; third-party wrappers would drift.
- Simple to adopt: one
npxline in any MCP client config; zero configuration required for the default behavior. - Faithful: the tool result is the same schema-v2 envelope the CLI prints; no re-shaping, no lossy summarization.
- Bounded and safe on untrusted input, inheriting the engine's budgets and adding process-level guardrails (hard timeout, concurrency cap, optional path allow-list).
- Standard open-source project: MIT, tests, CI matrix, provenance-signed npm releases, MCP Registry listing, CONTRIBUTING/SECURITY docs.
- Text extraction, rendering, OCR, macro execution — out of scope upstream, out of scope here.
- Streamable HTTP transport / remote hosting (see Future work).
- Accepting document bytes inline (base64) — stdio clients are local and can pass paths; revisit with the HTTP transport.
- Re-exposing every CLI flag (
-ooptional targets,-Oformat options,--plan,--strict,--telemetry). Each tool field costs schema tokens in every client session; v1 exposes only the options agents demonstrably need. - Persistent caching of probe results.
Facts about the existing stack that shape the design:
- CLI contract (CLI reference):
exactly one JSON value on stdout per input — a schema-v2 report, a values
view, or an error envelope with stable
code/message/exit_code. Exit statuses0–6carry the verdict. stderr is never part of the contract. - Execution modes: the README recommends persistent
--jsonlfor services and batches — one long-lived process, one compact JSON per input line, per-record errors that do not stop the run. - npm package
@deckflow/deckprobeships the native binary through per-platform optional dependencies (same bytes as the standalone installers), plus a WASMprobeFile()API. The WASM path holds the whole file in memory; the native CLI reads only the paths a probe needs. - Discovery is built in:
deckprobe formats,deckprobe targets --format <fmt>, anddeckprobe schemaare machine-readable and versioned with the binary. - Engine safety: never renders, never runs macros, never follows external references, never opens the network; physical-read / decompression / archive-entry / wall-clock budgets are enforced per probe.
MCP client (Claude Code / Desktop / Cursor / …)
│ JSON-RPC over stdio
▼
deckprobe-mcp (Node ≥ 20, TypeScript, ESM)
├─ server.ts McpServer: tools, resource, instructions
├─ tools/… zod schemas → engine calls → result mapping
├─ engine.ts binary resolution · spawn · JSON parse · exit-code map
└─ config.ts env-based limits and allow-list
│ argv + stdin/stdout (single-shot or --jsonl)
▼
deckprobe native CLI (from the @deckflow/deckprobe dependency)
| Native CLI subprocess | WASM probeFile() |
|
|---|---|---|
| I/O behavior | Reads only the byte ranges the plan needs | Buffers the entire file in memory |
| Contract | Bit-identical to every other DeckProbe surface | Same engine, but a second integration path to test |
| Isolation | Untrusted parsing in a separate OS process; hard kill possible | In-process; a pathological input shares the server's heap |
| Availability | Guaranteed by the npm dependency on supported platforms | Works everywhere Node runs |
The native CLI wins on memory behavior, process isolation, and contract
parity. The WASM path remains a documented fallback for platforms without
a prebuilt binary (the launcher's unsupported-platform error triggers it), so
npx works everywhere Node does. The fallback sets source_kind: "node_bytes" and is labeled in server logs; behavior is otherwise identical
because both run the same Rust engine.
DECKPROBE_MCP_BIN— explicit path, for development and packagers.- The platform binary installed by the server's own
@deckflow/deckprobedependency (resolved the same waybin/deckprobe.jsdoes — via the per-platform optional package). This is the default: the engine version is pinned by the server's lockfile, so behavior is deterministic. deckprobeonPATH— covers--no-optionalinstalls and source builds.- WASM fallback (§4.1).
The resolved engine and its tool_version are logged once at startup (to
stderr, never stdout — stdout belongs to the MCP transport).
- Single probes: one short-lived process per call
(
deckprobe [flags] <path>), stdout captured and parsed as one JSON value. - Batches: one
deckprobe --jsonl [flags]process perprobe_batchcall; the server writes one{"path": …}record per line, closes stdin, and parses one JSON value per output line. Per-record errors arrive as in-place error envelopes and do not abort the batch. - A concurrency semaphore (default 4) bounds simultaneous engine processes across all tools.
- A hard timeout (default 30 s per tool call) kills the child with SIGKILL after a grace SIGTERM. The engine's own wall-clock budget (500 ms / 5 s) normally fires first; the process-level deadline is the backstop the CLI reference itself recommends.
Server identity: name deckprobe, version = package version. Registered with
title: "DeckProbe" and an instructions string (§5.5). All tools carry
annotations readOnlyHint: true, openWorldHint: false — the server only
reads local files and never touches the network.
Probe one local document. The primary tool; its description tells the agent
to start with targets: ["@summary"] when unsure, and to consult
list_targets before naming an unverified target.
Input schema (zod v4; field names use snake_case like the report itself):
{
path: z.string().describe("Local path to the document (.pdf, .docx, .xlsx, .pptx, .doc, .xls, .ppt, .key, .numbers, .pages, …)"),
targets: z.array(z.string()).optional()
.describe("Short names (slide_count), canonical names (powerpoint.slide_count), or @presets (@summary, @security, @structure, @assets, @quality, @all). Default: the driver's @default set."),
level: z.enum(["header", "metadata", "deep"]).optional()
.describe("Probe budget/paths. Default metadata. Use deep only when a target's min_level requires it."),
min_confidence: z.enum(["low", "medium", "high", "exact"]).optional()
.describe("Minimum acceptable evidence confidence. Default high. Lower it when a target comes back unresolved and an approximation is acceptable."),
target_confidence: z.record(z.string(), z.enum(["low", "medium", "high", "exact"])).optional()
.describe("Per-target confidence overrides, e.g. {\"slide_count\": \"exact\"}."),
view: z.enum(["report", "values"]).optional()
.describe("report (default): full evidence envelope. values: compact target→value map — cheaper when confidence/evidence are not needed."),
budget: z.object({
max_physical_bytes: z.number().int().positive().optional(),
max_expanded_bytes: z.number().int().positive().optional(),
max_archive_entries: z.number().int().positive().optional(),
timeout_ms: z.number().int().positive().optional(),
}).optional().describe("Override the level's resource limits for hostile or oversized inputs."),
}Mapping to the CLI is 1:1: -t, -l, -c, -C k=v, --view values,
-b/-x/-e/-T. Nothing else is generated; there is no arbitrary
argument passthrough.
Result: the engine's stdout JSON, verbatim, as both a text content block
and structuredContent. outputSchema is a permissive zod mirror of the
envelope's top level (schema_version, status, input, driver,
results, execution, diagnostics); the authoritative contract remains
the upstream JSON Schema, exposed as a resource (§5.4), and the server never
re-validates engine output against it.
Inventory many documents in one call via one persistent --jsonl process.
{
paths: z.array(z.string()).min(1).max(64), // cap configurable, default 64
// shared options, same semantics as probe:
targets, level, min_confidence, view, budget
}viewdefaults to"values"here — batch calls are inventory-shaped and the full envelope for 64 files is context they rarely need. (--viewis a global CLI flag, so it applies uniformly to every JSONL record.)- Result:
structuredContent: { reports: [...] }, one entry per input path in order, each either a report/values view or a per-file error envelope. The call itself only fails (isError: true) when the batch could not run at all.
Thin wrappers over the discovery commands, so agents never guess a target name:
list_formats— no arguments →deckprobe formats(drivers, profiles, support boundaries).list_targets—{ format: z.string(), detail?: "compact" | "full" }→deckprobe targets --format <fmt>(ids, aliases, value types, selector membership,min_level, cost class, selector expansions).
Discovery output is static per engine binary, so results are cached in-process keyed by the resolved engine version; the first call per session pays the subprocess cost, later calls are free.
deckprobe://schema(application/json) — the exact bundled report JSON Schema, obtained fromdeckprobe schemaat first read and cached. For clients and integrators generating typed consumers.
Discovery data stays tool-shaped (§5.3) because tools are universally supported by clients; resources are a bonus surface, not a dependency.
A ~20-line digest of the Agent Skill, shipped in the initialize response so every client session learns the vocabulary without a skill install:
- start with
probe+@summary;@securityfor risk triage;@structurefor counts; never guess target names — calllist_targets; partialis not failure — checkexecution.unresolved_targets; onlyresolved/estimatedresults carry avalue;unknownusually means the document simply does not record that fact;confidence_scoreis a fixed label constant, not a calibrated probability;- the engine never renders, never executes macros, never opens the network — reports are facts about the document, not its text content.
The engine's error envelope is the error message. Mapping:
| Engine outcome | MCP result |
|---|---|
| Exit 0 | Normal result (ok or partial report — partial is not an error) |
| Exit 1–4 (invalid request / bad path / unsupported format / malformed input or budget) | isError: true; content = the error envelope JSON, plus one actionable hint line (e.g. exit 1 → "run list_targets for this format"; exit 4 → "raise budget.* or the file is damaged") |
| Exit 6 (engine bug) | isError: true, envelope + link to upstream issue tracker |
| Child killed by hard timeout | isError: true, synthesized envelope {code: "MCP_TIMEOUT", …} naming the configured limit |
| stdout unparsable / spawn failure | isError: true, synthesized MCP_ENGINE_FAILURE with stderr tail for diagnosis |
(Exit 5 cannot occur — the server never passes --strict; unresolved targets
are visible in the report and the agent decides.)
Pre-engine validation failures — nonexistent path, path outside the
allow-list, batch too large — are rejected by the server without spawning,
using the same envelope shape with MCP_-prefixed codes so consumers parse
one error grammar everywhere.
All configuration is environment variables (set in the client's MCP config);
no config file, no flags beyond --version/--help.
| Variable | Default | Meaning |
|---|---|---|
DECKPROBE_MCP_BIN |
– | Explicit engine binary path (dev/packagers) |
DECKPROBE_MCP_ROOTS |
unrestricted | path.delimiter-separated directory allow-list; when set, every input path must resolve (after realpath) inside one of them |
DECKPROBE_MCP_TIMEOUT_MS |
30000 |
Hard per-call child deadline |
DECKPROBE_MCP_MAX_CONCURRENCY |
4 |
Max simultaneous engine processes |
DECKPROBE_MCP_MAX_BATCH |
64 |
Max paths per probe_batch call |
Security posture:
- Untrusted documents are the engine's problem, and its whole design — bounded parsing in v1's separate OS process, no rendering, no macro execution, no network. The server adds the hard-kill deadline on top.
- File system reach: reports expose facts about a document (metadata,
counts, signals), never its content, so exfiltration value is low; still,
DECKPROBE_MCP_ROOTSlets operators of shared or automated deployments pin the reachable tree. Default is unrestricted, matching the CLI the user already runs locally. Symlinks are resolved before the allow-list check. - Prompt-injection surface: report values (e.g. a document title) are attacker-controlled strings. The server passes them through as data inside JSON — it never interpolates them into instructions or descriptions.
- stdout hygiene: the MCP transport owns stdout; all logging goes to stderr.
Standalone repo, standard single-package layout:
deckprobe-mcp-server/
├── src/
│ ├── main.ts # bin entry: --version/--help, connect stdio
│ ├── server.ts # buildServer(): registrations + instructions
│ ├── engine.ts # resolution, spawn, JSONL, exit-code mapping
│ ├── config.ts # env parsing + defaults
│ ├── schemas.ts # shared zod schemas (probe options, envelope mirror)
│ └── tools/
│ ├── probe.ts
│ ├── probe-batch.ts
│ └── discovery.ts # list_formats, list_targets, schema resource
├── tests/
│ ├── fixtures/ # tiny committed documents: pdf, docx, xlsx, pptx, key, corrupt.pdf, renamed.pptx→docx
│ ├── engine.test.ts
│ ├── tools.test.ts # InMemory client ↔ server, no real transport
│ └── stdio.e2e.test.ts # spawn the built bin, drive it with the MCP client SDK
├── docs/rfc.md # this document
├── .github/workflows/ # ci.yml, release.yml
├── package.json tsconfig.json biome.json
├── README.md LICENSE CHANGELOG.md CONTRIBUTING.md SECURITY.md
└── server.json # MCP Registry manifest
| Choice | Decision | Rationale |
|---|---|---|
| Runtime | Node ≥ 20, ESM only | Matches @deckflow/deckprobe engines field |
| Language | TypeScript, strict | – |
| MCP SDK | @modelcontextprotocol/server v2 (stable line, 2026-07-28 spec) + zod v4 |
Current stable SDK; zod is its native schema path |
| Engine | @deckflow/deckprobe ^2.x (regular dependency) |
Pins the binary; lockfile makes behavior reproducible |
| Build | plain tsc |
No bundling needed for a Node bin; mirrors deckprobe-js |
| Lint/format | Biome | One dev dependency for both |
| Tests | Vitest + @modelcontextprotocol/client |
InMemory transport keeps tool tests fast; one e2e over real stdio |
Runtime dependency count: 3 (@modelcontextprotocol/server, zod,
@deckflow/deckprobe). Keeping this list short is a feature.
package.json essentials:
- Unit (
engine.test.ts): binary resolution order, flag generation from option objects, exit-code mapping, timeout kill, JSONL framing, allow-listrealpathchecks. - Tool-level (
tools.test.ts): client↔server over the InMemory transport against committed fixtures — golden assertions onstructuredContent(deterministic by upstream design: noelapsed_mswithout--telemetry), error mapping for a corrupt PDF (exit 4) and an extension-mismatch file (MALFORMED_INPUT), discovery caching, batch ordering with an embedded per-record error. - E2E (
stdio.e2e.test.ts): build, spawndist/main.js, initialize, list tools, callprobeon a fixture — the exact path every client takes. - Fixtures are small (a few KB each) and committed; no network in tests.
- ci.yml: push/PR matrix —
ubuntu-latest,macos-latest,windows-latest× Node 20/22 — running Biome,tsc --noEmit, and Vitest. The matrix doubles as a test of platform-binary resolution on all three OSes. - release.yml: on tag
v*— build, test,npm publishwith--provenance(matching the upstream project's provenance practice), then publishserver.jsonto the MCP Registry. CHANGELOG follows Keep a Changelog; versioning is independent SemVer (the engine dependency range, not the server version, tracks DeckProbe releases).
# Claude Code
claude mcp add deckprobe -- npx -y @deckflow/deckprobe-mcp// Claude Desktop / Cursor / VS Code — mcpServers entry
{
"deckprobe": {
"command": "npx",
"args": ["-y", "@deckflow/deckprobe-mcp"]
// optional: "env": { "DECKPROBE_MCP_ROOTS": "/Users/me/Documents" }
}
}The README also covers: global install (npm i -g), pointing
DECKPROBE_MCP_BIN at a cargo-built binary, the tool catalogue with one
worked example per tool, and a "MCP server vs. Agent Skill" paragraph
(shell-capable agents may prefer the skill; everything else uses MCP; both
teach the same vocabulary).
- M1 — Engine + probe: scaffold,
engine.ts(resolution, spawn, exit-code map, timeout),probetool, unit tests. Usable end-to-end. - M2 — Full surface:
probe_batch(JSONL),list_formats,list_targets, schema resource, instructions, discovery cache, allow-list. - M3 — Project hygiene: tool + e2e tests, CI matrix, README, CONTRIBUTING, SECURITY, CHANGELOG.
- M4 — Release:
v0.1.0to npm with provenance, MCP Registry listing, upstream README/skill cross-links.
- Streamable HTTP transport behind a flag, for hosted deployments — brings auth, origin validation, and the base64 bytes input along with it.
- Inline bytes input (
name+data_base64, bounded) once a remote transport makes it meaningful; the CLI's JSONL byte records already support it. - WASM-only distribution if platform coverage gaps show up in practice.
- Honoring client-provided MCP roots as an additional allow-list source
(v1 treats env
DECKPROBE_MCP_ROOTSas the only enforcement point). - Optional-target (
-o) and format-option (-O) passthrough if real agent traces show demand.
- Monorepo placement (
packages/deckprobe-mcpupstream): better version coupling, but the upstream repo is Rust-first with a WASM build chain in its release path; a standalone TS repo releases independently and stays trivial to contribute to. The npm dependency provides the coupling that matters. Can be folded upstream later without breaking the package name. - WASM SDK as the primary engine: rejected for memory behavior and process isolation (§4.1); kept as fallback.
- One mega-tool with a
modefield: fewer tools but a worse schema — clients surface tool names as affordances, andprobevs. discovery have disjoint inputs. - Exposing
--strict: meaningless over MCP; exit codes are not the signaling channel,execution.unresolved_targetsis.
- Package name — confirmed as
@deckflow/deckprobe-mcp, bindeckprobe-mcp, registry nameio.github.deckflow/deckprobe. - Globs in
probe_batch— not accepted. Literal paths only; the agent expands globs, which keeps the server free of a second path grammar and of filesystem traversal it would then have to bound. DECKPROBE_MCP_ROOTSin hosted contexts — still open, and deferred with the HTTP transport it belongs to. The variable exists and is enforced; only its default in a hosted deployment is undecided.
Where v0.1.0 departs from the proposal above, and why.
list_targetsreturns a compact catalogue by default. The full engine report is roughly 22 KB per format — about 6k tokens — most of it per-target JSON Schema fragments and fully expanded selector member lists that an agent choosing a target never reads. The compact projection keeps every field needed to name a target correctly at roughly a third of the size, anddetail: "full"returns the engine report verbatim. This is the one place besides the batch wrapper where output is reshaped, and it is opt-out.- Each
probe_batchentry carries its requestedpath. The engine's error envelope has noinputfield, so a failed record is otherwise indistinguishable from any other and correlation would rest on array position alone. Entries are{ path, report }with the report verbatim. - Text blocks are indented only below 8 KB. Indenting a long batch or a
full target catalogue roughly doubles it for no gain, and
structuredContentcarries the same value for anything parsing it. - Errors are keyed on
error.code, not the exit status. Status 4 covers bothMALFORMED_INPUTandBUDGET_EXCEEDED, which call for opposite responses; the exit status is only the fallback. - Pre-engine rejections inside a batch stay per-file. A missing path or one outside the allow-list becomes an error envelope in its own entry rather than failing the call, matching how the engine's JSONL mode treats a bad record.
- The WebAssembly fallback derives the values view itself. The SDK has no
--viewequivalent and always resolves the full report. The projection is asserted equal to the CLI's own values envelope inengine.test.ts, so the two paths cannot drift silently. outputSchemais declared and deliberately permissive. The SDK skips output validation onisErrorresults and does not strip fields fromstructuredContent, so a loose envelope schema documents the shape without ever rejecting a valid report. The authoritative contract remains thedeckprobe://schemaresource.
{ "name": "@deckflow/deckprobe-mcp", "type": "module", "bin": { "deckprobe-mcp": "./dist/main.js" }, "engines": { "node": ">=20" }, "publishConfig": { "access": "public", "provenance": true } }