Skip to content

Latest commit

 

History

History
493 lines (410 loc) · 24.2 KB

File metadata and controls

493 lines (410 loc) · 24.2 KB

RFC: DeckProbe MCP Server

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

1. Summary

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 toolsprobe, 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.

2. Motivation

  • 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.

Goals

  1. Simple to adopt: one npx line in any MCP client config; zero configuration required for the default behavior.
  2. Faithful: the tool result is the same schema-v2 envelope the CLI prints; no re-shaping, no lossy summarization.
  3. Bounded and safe on untrusted input, inheriting the engine's budgets and adding process-level guardrails (hard timeout, concurrency cap, optional path allow-list).
  4. Standard open-source project: MIT, tests, CI matrix, provenance-signed npm releases, MCP Registry listing, CONTRIBUTING/SECURITY docs.

Non-goals (v1)

  • 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 (-o optional targets, -O format 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.

3. Background

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 statuses 0–6 carry the verdict. stderr is never part of the contract.
  • Execution modes: the README recommends persistent --jsonl for 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/deckprobe ships the native binary through per-platform optional dependencies (same bytes as the standalone installers), plus a WASM probeFile() 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>, and deckprobe schema are 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.

4. High-level design

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)

4.1 Why spawn the CLI instead of calling the WASM SDK

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.

4.2 Binary resolution order

  1. DECKPROBE_MCP_BIN — explicit path, for development and packagers.
  2. The platform binary installed by the server's own @deckflow/deckprobe dependency (resolved the same way bin/deckprobe.js does — via the per-platform optional package). This is the default: the engine version is pinned by the server's lockfile, so behavior is deterministic.
  3. deckprobe on PATH — covers --no-optional installs and source builds.
  4. 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).

4.3 Process model

  • 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 per probe_batch call; 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.

5. MCP surface

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.

5.1 Tool: probe

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.

5.2 Tool: probe_batch

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
}
  • view defaults to "values" here — batch calls are inventory-shaped and the full envelope for 64 files is context they rarely need. (--view is 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.

5.3 Tools: list_formats, list_targets

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.

5.4 Resource: report schema

  • deckprobe://schema (application/json) — the exact bundled report JSON Schema, obtained from deckprobe schema at 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.

5.5 Server instructions

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; @security for risk triage; @structure for counts; never guess target names — call list_targets;
  • partial is not failure — check execution.unresolved_targets; only resolved/estimated results carry a value; unknown usually means the document simply does not record that fact;
  • confidence_score is 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.

6. Error handling

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.

7. Configuration and security

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_ROOTS lets 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.

8. Repository layout and tech stack

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:

{
  "name": "@deckflow/deckprobe-mcp",
  "type": "module",
  "bin": { "deckprobe-mcp": "./dist/main.js" },
  "engines": { "node": ">=20" },
  "publishConfig": { "access": "public", "provenance": true }
}

9. Testing

  • Unit (engine.test.ts): binary resolution order, flag generation from option objects, exit-code mapping, timeout kill, JSONL framing, allow-list realpath checks.
  • Tool-level (tools.test.ts): client↔server over the InMemory transport against committed fixtures — golden assertions on structuredContent (deterministic by upstream design: no elapsed_ms without --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, spawn dist/main.js, initialize, list tools, call probe on a fixture — the exact path every client takes.
  • Fixtures are small (a few KB each) and committed; no network in tests.

10. CI / CD

  • 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 publish with --provenance (matching the upstream project's provenance practice), then publish server.json to the MCP Registry. CHANGELOG follows Keep a Changelog; versioning is independent SemVer (the engine dependency range, not the server version, tracks DeckProbe releases).

11. Client setup (README content)

# 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).

12. Milestones

  1. M1 — Engine + probe: scaffold, engine.ts (resolution, spawn, exit-code map, timeout), probe tool, unit tests. Usable end-to-end.
  2. M2 — Full surface: probe_batch (JSONL), list_formats, list_targets, schema resource, instructions, discovery cache, allow-list.
  3. M3 — Project hygiene: tool + e2e tests, CI matrix, README, CONTRIBUTING, SECURITY, CHANGELOG.
  4. M4 — Release: v0.1.0 to npm with provenance, MCP Registry listing, upstream README/skill cross-links.

13. Future work

  • 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_ROOTS as the only enforcement point).
  • Optional-target (-o) and format-option (-O) passthrough if real agent traces show demand.

14. Alternatives considered

  • Monorepo placement (packages/deckprobe-mcp upstream): 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 mode field: fewer tools but a worse schema — clients surface tool names as affordances, and probe vs. discovery have disjoint inputs.
  • Exposing --strict: meaningless over MCP; exit codes are not the signaling channel, execution.unresolved_targets is.

15. Resolved questions

  1. Package name — confirmed as @deckflow/deckprobe-mcp, bin deckprobe-mcp, registry name io.github.deckflow/deckprobe.
  2. 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.
  3. DECKPROBE_MCP_ROOTS in 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.

16. Implementation notes

Where v0.1.0 departs from the proposal above, and why.

  • list_targets returns 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, and detail: "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_batch entry carries its requested path. The engine's error envelope has no input field, 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 structuredContent carries the same value for anything parsing it.
  • Errors are keyed on error.code, not the exit status. Status 4 covers both MALFORMED_INPUT and BUDGET_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 --view equivalent and always resolves the full report. The projection is asserted equal to the CLI's own values envelope in engine.test.ts, so the two paths cannot drift silently.
  • outputSchema is declared and deliberately permissive. The SDK skips output validation on isError results and does not strip fields from structuredContent, so a loose envelope schema documents the shape without ever rejecting a valid report. The authoritative contract remains the deckprobe://schema resource.