This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Build (requires .NET 10 SDK)
dotnet build
# Run unit tests (xUnit, ~428 tests)
dotnet test tests/DocxMcp.Tests/
# Run tests with Cloudflare R2 backend (dual-server mode)
# 1. Get credentials: source infra/env-setup.sh
# 2. Build: cargo build --release -p docx-storage-cloudflare
# 3. Launch server (in background):
# CLOUDFLARE_ACCOUNT_ID=... R2_BUCKET_NAME=... R2_ACCESS_KEY_ID=... \
# R2_SECRET_ACCESS_KEY=... GRPC_PORT=50052 \
# ./target/release/docx-storage-cloudflare &
# 4. Run tests:
STORAGE_GRPC_URL=http://localhost:50052 dotnet test tests/DocxMcp.Tests/
# Run a single test by name
dotnet test tests/DocxMcp.Tests/ --filter "FullyQualifiedName~TestMethodName"
# Run tests in a single class
dotnet test tests/DocxMcp.Tests/ --filter "FullyQualifiedName~PathParserTests"
# Publish NativeAOT binaries (outputs to dist/)
./publish.sh # auto-detect current platform
./publish.sh macos-arm64 # specific target
./publish.sh all # all 6 platform targets
# Integration tests (requires mcptools: brew install mcptools)
./test-mcp.sh # new document
./test-mcp.sh ~/Documents/sample.docx # existing file
# Run MCP server directly (stdio transport)
dotnet run --project src/DocxMcp/This is an MCP (Model Context Protocol) server and standalone CLI for programmatic DOCX manipulation, built with .NET 10 and NativeAOT.
- DocxMcp (
src/DocxMcp/) — MCP server. Entry point registers tool classes with the MCP SDK viaWithTools<T>()inProgram.cs. - DocxMcp.Cli (
src/DocxMcp.Cli/) — Standalone CLI mirroring all MCP tools as shell commands. References DocxMcp as a library. - DocxMcp.Tests (
tests/DocxMcp.Tests/) — xUnit tests covering paths, patching, querying, styling, comments, undo/redo, persistence, and concurrency.
MCP stdio / CLI command
→ Tool classes (src/DocxMcp/Tools/)
→ SessionManager (session lifecycle + undo/redo + WAL coordination)
→ DocxSession (in-memory MemoryStream + WordprocessingDocument)
→ Open XML SDK (DocumentFormat.OpenXml)
→ SyncManager (file sync + auto-save, caller-orchestrated)
Storage is split into two interfaces for dual-server deployment:
IHistoryStorage— Sessions, WAL, index, checkpoints. Maps toStorageServicegRPC. Can be remote (Cloudflare R2) or local embedded.ISyncStorage— File sync + filesystem watch. Maps toSourceSyncService+ExternalWatchServicegRPC. Always local (embedded staticlib).
Implemented by HistoryStorageClient and SyncStorageClient.
Embedded mode (no STORAGE_GRPC_URL): Both use in-memory channel via NativeStorage.Init() + InMemoryPipeStream (P/Invoke to Rust staticlib).
Dual mode (STORAGE_GRPC_URL set): IHistoryStorage → remote gRPC, ISyncStorage → local embedded staticlib. Auto-save orchestration is caller-side: after each mutation, tool classes call sync.MaybeAutoSave().
The Rust storage server binary (dist/{platform}/docx-storage-local) is auto-launched by GrpcLauncher via Unix socket. After modifying Rust code, rebuild and copy: cargo build --release -p docx-storage-local && cp target/release/docx-storage-local dist/macos-arm64/
Documents are navigated via typed paths like /body/table[0]/row[1]/cell[0]/paragraph[*].
- PathSegment — 14 discriminated union record types (Body, Paragraph, Table, Row, Cell, Run, etc.)
- PathParser — Parses string paths into typed
DocxPathwith validation viaPathSchema - PathResolver — Resolves
DocxPathto Open XML elements using SDK typed accessors - Selectors —
[0](index),[-1](last),[*](all),[text~='...'](text match),[style='...'](style match)
RFC 6902-adapted JSON patches with ops: add, replace, remove, move, copy, replace_text, remove_column. Max 10 operations per call. ElementFactory converts JSON value definitions into Open XML elements.
- SessionStore — Disk I/O for baselines and WAL files
- MappedWal — Memory-mapped WAL (JSONL) with random access for efficient undo/redo
- SessionIndex — JSON metadata tracking sessions, WAL counts, cursor positions, checkpoint markers
- SessionLock — Cross-process file locking (file-based
.lockwith exponential backoff) - Checkpoints — Full document snapshots every N edits (default 10, via
DOCX_CHECKPOINT_INTERVAL) - Undo rebuilds from nearest checkpoint then replays. Redo replays forward patches (no rebuild).
Three tools: style_element, style_paragraph, style_table. All use merge semantics — only specified properties change, others are preserved.
Tools use attribute-based registration with DI:
[McpServerToolType]
public sealed class SomeTools
{
[McpServerTool(Name = "tool_name"), Description("...")]
public static string ToolMethod(SessionManager sessions, SyncManager sync,
ExternalChangeTracker? externalChangeTracker, string param) { ... }
}SessionManager, SyncManager, and ExternalChangeTracker are auto-injected from the DI container. Mutation tools call sync.MaybeAutoSave() after sessions.AppendWal().
| Variable | Default | Purpose |
|---|---|---|
DOCX_SESSIONS_DIR |
<LocalApplicationData>/docx-mcp/sessions |
Session storage location (macOS: ~/Library/Application Support/, Linux: ~/.local/share/, Windows: AppData\Local\) |
DOCX_CHECKPOINT_INTERVAL |
10 |
Edits between checkpoints |
DOCX_WAL_COMPACT_THRESHOLD |
50 |
WAL entries before compaction |
DOCX_AUTO_SAVE |
true |
Auto-save to source file after each edit |
STORAGE_GRPC_URL |
(unset) | Remote gRPC URL for history storage (enables dual-server mode) |
SYNC_GRPC_URL |
(unset) | Remote gRPC URL for sync/watch (e.g. http://gdrive:50052). Falls back to STORAGE_GRPC_URL if unset |
Two profiles are available:
proxy— Local development.docx-storage-localserves all 3 gRPC services on:50051(history + sync/watch for local files).cloud— Production.docx-storage-cloudflare(R2, StorageService) +docx-storage-gdrive(SourceSyncService + ExternalWatchService, multi-tenant OAuth tokens from D1).
# Always source credentials first
source infra/env-setup.sh
# Local dev
docker compose --profile proxy up -d
# Production (R2 + Google Drive)
docker compose --profile cloud up -dGoogle Drive sync uses per-tenant OAuth tokens stored in D1 (oauth_connection table). The gdrive server never holds static credentials — each operation resolves the token from D1 via TokenManager.
Flow: Website OAuth consent → tokens stored in D1 → gdrive server reads tokens per-connection → auto-refresh via refresh_token grant.
URI format: gdrive://{connection_id}/{file_id} — the connection_id identifies which OAuth connection (and thus which Google account) to use.
Key files:
- Config:
crates/docx-storage-gdrive/src/config.rs - D1 client:
crates/docx-storage-gdrive/src/d1_client.rs - Token manager:
crates/docx-storage-gdrive/src/token_manager.rs - GDrive API:
crates/docx-storage-gdrive/src/gdrive.rs - OAuth routes:
website/src/pages/api/oauth/ - OAuth connections lib:
website/src/lib/oauth-connections.ts - D1 migration:
website/migrations/0005_oauth_connections.sql
Four services on Koyeb app docx-mcp, managed via Pulumi (infra/__main__.py):
| Service | Dockerfile | Port | Protocol | Route | Instance |
|---|---|---|---|---|---|
storage |
Dockerfile.storage-cloudflare |
50051 | tcp (mesh-only) | (none) | nano |
gdrive |
Dockerfile.gdrive |
50052 | tcp (mesh-only) | (none) | nano |
mcp-http |
Dockerfile |
3000 | tcp (mesh-only) | (none) | small |
proxy |
Dockerfile.proxy |
8080 | http (public) | / |
nano |
Internal services use protocol=tcp with no routes — they are only reachable via Koyeb service mesh (e.g. http://storage:50051). This prevents route conflicts: if multiple services in the same app share route /, Koyeb's edge routes traffic to the wrong service → 502.
Custom domain: mcp.docx.lapoule.dev → Koyeb CNAME (DNS-only, no Cloudflare proxy)
# Always source credentials first
source infra/env-setup.sh
# List services
koyeb services list --app docx-mcp
# Describe a service (routing, scaling, git sha)
koyeb services describe docx-mcp/<name>
koyeb services describe docx-mcp/<name> -o json
# List deployments for a service
koyeb deployments list --service docx-mcp/<name>
# Describe a deployment (definition, build status)
koyeb deployments describe <deployment-id>
koyeb deployments describe <deployment-id> -o json
# List running instances
koyeb instances list --app docx-mcp
# Instance logs (historical range)
koyeb instances logs <instance-id> --start-time "2026-02-19T18:00:00Z" --end-time "2026-02-19T19:30:00Z"
# Instance logs (tail, blocks until Ctrl-C)
koyeb instances logs <instance-id> --tail
# Update a service (e.g. scale-to-zero)
koyeb services update docx-mcp/<name> --min-scale 0
koyeb services update docx-mcp/<name> --min-scale 1
# Redeploy with latest commit
koyeb services update docx-mcp/<name> --git-sha ''
# Redeploy specific commit
koyeb services update docx-mcp/<name> --git-sha <sha>Koyeb CLI gotchas:
-o jsonoutputs one JSON object per line (not a JSON array) — usehead -1 | python3 -c "import json,sys; d=json.loads(sys.stdin.readline())"to parse--tailflag blocks forever (no--lineslimit) — usetimeout 10 koyeb instances logs <id> --tailor Ctrl-C- No
--type build/runtimeflag on logs — all logs are mixed koyeb logsdoes NOT exist — usekoyeb instances logs <instance-id>koyeb services logsexists but is unreliable (empty output) — preferkoyeb instances logskoyeb domains listhas no--appflag — lists all domains across all apps
Debugging 502 errors: Koyeb uses Cloudflare as its edge/CDN — a 502 with server: cloudflare and cf-ray headers comes from Koyeb's Cloudflare layer, not our own Cloudflare zone. Common causes:
- HTTP/2 (h2c) mismatch: Koyeb's edge may connect to containers via HTTP/2 cleartext (h2c). If the server only speaks HTTP/1.1 (e.g.
axum::serve), Koyeb returns 502 even though health checks pass (health checks use HTTP/1.1). Fix: usehyper-util::server::conn::auto::Builderfor dual-stack HTTP/1.1 + h2c. - Health check testing upstream: If
/healthchecks upstream services (e.g. mcp-http), it can timeout during cold start, making Koyeb mark the container as unhealthy. Fix:/healthshould only test that the proxy itself is running. Use/upstream-healthfor deep checks. - Upstream unreachable: If proxy is healthy but returns 502, check mcp-http logs first (
koyeb instances logs <mcp-http-instance-id> --tail).
IMPORTANT — PAT tokens: NEVER use fake/placeholder tokens like dxs_test or dxs_fake. The proxy validates every token against Cloudflare D1 and rejects invalid ones immediately. Always use a real PAT token from the D1 pat table (format: dxs_<40-char-hex>). To get one, query D1 directly or create one via the website.
Pulumi provider bug: scale_to_zero=True on mesh-only services (no public route) fails with Pulumi provider validation error "at least one route is required for services scaling to zero". The Koyeb API/CLI accepts it fine. Workaround: apply via CLI koyeb services update --min-scale 0, then set scale_to_zero=True in Pulumi to keep state aligned (Pulumi won't try to re-apply if already matching).
Always test Dockerfile changes locally before pushing:
# Build and verify (use --target to stop at a specific stage)
docker build -f Dockerfile --target runtime -t docx-mcp-test .
docker build -f Dockerfile.proxy -t proxy-test .
docker build -f Dockerfile.storage-cloudflare -t storage-test .
docker build -f Dockerfile.gdrive -t gdrive-test .
# Full stack local test
source infra/env-setup.sh && docker compose --profile proxy up -d --buildmcptools (brew install mcptools) speaks MCP protocol via stdio. For HTTP/SSE servers (proxy), use npx mcp-remote as a stdio-to-HTTP bridge.
Local proxy (docker compose):
# 1. Start the local stack
source infra/env-setup.sh && docker compose --profile proxy up -d
# 2. List all MCP tools via local proxy
mcptools tools npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_<real-pat>"
# 3. Call a specific tool
mcptools call document_list npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_<real-pat>"
# 4. Interactive shell (call tools one by one)
mcptools shell npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_<real-pat>"Koyeb proxy (production):
# Same commands, just change the URL to the Koyeb public endpoint
mcptools tools npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_<real-pat>"
mcptools call document_list npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_<real-pat>"
mcptools shell npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_<real-pat>"Direct stdio testing (no proxy, no docker):
# Test MCP server directly via stdio (embedded storage, local only)
mcptools tools dotnet run --project src/DocxMcp/
mcptools call document_list dotnet run --project src/DocxMcp/
mcptools shell dotnet run --project src/DocxMcp/mcptools gotchas:
- mcptools only speaks stdio natively — for HTTP/SSE servers always use
npx mcp-remote <url>as the command mcptools tools <url>does NOT work — it tries to exec the URL as a command- The
--headerflag is passed through tomcp-remote, not to mcptools itself mcptools configs lsshows servers from Claude Desktop/Code configs but you can't use them directly as aliases
koyeb instances exec requires a TTY — use script -q /dev/null wrapper:
# Test connectivity from inside a container
script -q /dev/null koyeb instances exec <instance-id> -- curl -s http://mcp-http:3000/health
# Test gRPC service (grpcurl is installed in mcp-http image)
script -q /dev/null koyeb instances exec <mcp-http-instance-id> -- grpcurl -plaintext storage:50051 list
script -q /dev/null koyeb instances exec <mcp-http-instance-id> -- grpcurl -plaintext storage:50051 storage.StorageService/HealthCheck- NativeAOT: All code must be AOT-compatible. Tool types are registered explicitly (no reflection-based discovery).
InvariantGlobalizationisfalse. - MCP stdio: All logging goes to stderr (
LogToStandardErrorThreshold = LogLevel.Trace). Stdout is reserved for MCP protocol messages. - Internal visibility:
DocxMcpexposes internals toDocxMcp.TestsviaInternalsVisibleTo. - No
apply_xml_patch: Deliberately omitted — raw XML patching is too fragile for LLM callers. Use the typed JSON patch system instead. - Pagination limits: Queries return max 50 elements; patches accept max 10 operations per call.