{"slug": "only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks", "title": "Only One Crosses the Bridge at a Time > Heimdall MCP Adds Resource Locks", "summary": "Heimdall MCP version 1.5.0 introduces resource locks to prevent concurrent agent sessions from overwriting each other's changes. The update adds lock rules for both MCP tools and host-native tools like Claude Code's Write/Edit, with configurable time-to-live and conflict handling. This addresses a failure mode where two calls to the same resource can land simultaneously, silently destroying one agent's work.", "body_md": "Run two Claude Code sessions against the same repo — one refactoring a module, one fixing a typo three files away that happens to also touch a shared config file — and eventually both sessions decide to write the same file within a few hundred milliseconds of each other. Nothing stops that. The second write wins, silently. The first agent's changes are gone, and neither session has any idea it happened.\n\nThe same thing happens one layer down, with MCP servers instead of native editor tools: an MCP filesystem server invoked by two agent processes, or a `run_migration`\n\ntool exposed by a database server, called twice in close succession by two unrelated sessions. Heimdall MCP already had tool-name policies (v1.2) and argument-level policies (v1.4) — you could say \"never call `write_file`\n\non `/etc/passwd`\n\n\" — but neither of those answers a different question: *what happens when two calls to the same resource land at the same time?*\n\nThat's the failure mode 1.5.0 is built to close.\n\nRepo: [https://github.com/enmanuelmag/heimdall-mcp](https://github.com/enmanuelmag/heimdall-mcp)\n\nWebsite: [https://stack.cardor.dev/heimdall](https://stack.cardor.dev/heimdall)\n\nVersion 1.5.0 adds **resource locks**, enforced at two levels that share the same underlying mechanism: MCP tool calls routed through Heimdall's proxy, and host-native tools (Claude Code's `Write`\n\n/`Edit`\n\n/`MultiEdit`\n\n/`NotebookEdit`\n\n, and OpenCode's equivalent) that never touch the proxy at all.\n\nA `locks`\n\nblock, keyed by tool name, sits alongside a server's existing `tools`\n\nand `toolPolicies`\n\nconfig:\n\n```\n// heimdall.config.ts\nexport default {\n  servers: {\n    filesystem: {\n      tools: { allow: ['read_file', 'write_file'] },\n      locks: {\n        write_file: { resource: 'path', ttl: 30_000 },\n        run_migration: { resource: 'db-migration', onConflict: 'warn' },\n      },\n    },\n  },\n} satisfies HeimdallConfig;\n```\n\nEach entry is a `LockRule`\n\n:\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n`resource` |\n`string` |\n— | Name of a tool-call argument whose value becomes the lock key (e.g. `resource: 'path'` locks on `arguments.path` ), or a literal key (e.g. `'db-migration'` ) if no argument by that name exists. Falls back to the tool name if omitted. Path-like values are canonicalized automatically. |\n`ttl` |\n`number` |\n`30000` |\nLock time-to-live in milliseconds — a backstop so a crashed or hung holder can't lock a resource forever. |\n`onConflict` |\n`'reject' \\ | 'warn'` | `'reject'` |\n\n`write_file`\n\nlocking on `path`\n\nmeans two calls to `write_file`\n\nwith the *same* path serialize; two calls with different paths never contend. `run_migration`\n\nlocking on the literal key `db-migration`\n\nmeans every call to that tool serializes against every other call, regardless of arguments — useful when the resource being protected isn't any single argument, it's \"the database.\"\n\nMCP servers only cover part of an agent's surface area. Claude Code's own file-editing tools — `Write`\n\n, `Edit`\n\n, `MultiEdit`\n\n, `NotebookEdit`\n\n— are built into the coding agent itself. They're never MCP servers, so they never pass through Heimdall's JSON-RPC proxy, and no policy layer that hooks into `tools/call`\n\ncan see them.\n\n1.5.0 adds a `hosts`\n\nfield, a sibling of `servers`\n\n/`default`\n\n, that applies the exact same `LockRule`\n\nshape to these native tools:\n\n```\n// heimdall.config.ts\nexport default {\n  hosts: {\n    'claude-code': {\n      locks: {\n        Write: { resource: 'file_path', ttl: 30_000 },\n        Edit: { resource: 'file_path', ttl: 30_000 },\n        MultiEdit: { resource: 'file_path', ttl: 30_000 },\n        NotebookEdit: { resource: 'file_path', ttl: 30_000 },\n      },\n    },\n  },\n} satisfies HeimdallConfig;\n```\n\n`@cardor/heimdall-mcp`\n\nexports `CLAUDE_CODE_DEFAULT_HOST_POLICY`\n\n— exactly this policy — applied automatically as a baseline whenever neither your local nor global config sets `hosts['claude-code']`\n\n. You don't have to write it yourself to get the protection; you only write it if you want to change it.\n\n**Why Bash is excluded.** An arbitrary shell command has no single stable \"resource\" argument. It might touch zero files, one file, or a dozen. Locking on the command string would be meaningless; locking Bash calls globally would serialize unrelated work for no reason. So\n\n`Bash`\n\nhas no lock rule in the default policy, and none is recommended.There's no proxy in the loop for native tools, so this required a genuinely different enforcement path: `bin/hooks/claude-pretooluse.js`\n\n, a real `PreToolUse`\n\nhook script. On every invocation it:\n\n`tool_name`\n\n/ `tool_input`\n\nfrom stdin`heimdall.config.*`\n\nfresh from disk — local and global, merged — never cached`hosts['claude-code'].locks`\n\n`~/.config/heimdall/locks.db`\n\nby default)If the resource is free, the call proceeds. If it's held, the call is denied with a human-readable reason. Critically, the hook **fails open**: any unexpected internal error results in exit code `0`\n\n(allow), never a hard block — a bug in the hook must never brick a Claude Code session.\n\nRegister it with:\n\n```\nheimdall-mcp init --hooks claude-code\n```\n\nThis reads (or creates) `~/.claude/settings.json`\n\n, resolves the absolute path to the installed package's hook script, and appends a `PreToolUse`\n\nentry — without touching any other `hooks.*`\n\nentries or unrelated settings keys. It's idempotent: running it twice makes no changes the second time, and the write is atomic (temp-file-then-rename), so the settings file is never left partially written.\n\nOpenCode's equivalent, `bin/plugins/opencode-heimdall.js`\n\n, follows the same config-loading and lock-acquisition logic but is architecturally different: instead of a subprocess spawned per call over stdin/stdout, OpenCode resolves the plugin once via `import()`\n\nat startup and it runs in-process, matched against `hosts['opencode']`\n\nfor the rest of the session.\n\nThere is no built-in default policy for OpenCode yet — no `OPENCODE_DEFAULT_HOST_POLICY`\n\n— so calls are allowed until you configure `hosts.opencode.locks`\n\nexplicitly:\n\n```\nhosts: {\n  opencode: {\n    locks: {\n      write: { resource: 'filePath', ttl: 30_000 },\n    },\n  },\n},\n```\n\n`heimdall-mcp init --hooks opencode`\n\nregisters it into `~/.config/opencode/opencode.jsonc`\n\n's `plugin`\n\narray, using `jsonc-parser`\n\n(the same library VS Code uses internally) to make a surgical text edit rather than a `JSON.parse`\n\n/`stringify`\n\nround trip — so existing comments and formatting elsewhere in the file survive untouched.\n\nHere's the part worth saying plainly rather than glossing over: the plugin denies a conflicting lock by **throwing an Error** from the hook callback, on the assumption that a thrown error aborts the tool call — a reasonable assumption for a void-returning before-hook, and nothing found while researching OpenCode's source contradicts it, but it has\n\n`init --hooks opencode`\n\nregistration path itself — verified with high confidence against OpenCode's fetched source and the installed binary's embedded strings, but not validated end-to-end against a real running OpenCode process actually loading the plugin. Both are documented as experimental until independently verified, not shipped as if they were fully proven.A rejected `tools/call`\n\nreturns a distinct error code with structured holder info:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 42,\n  \"error\": {\n    \"code\": -32600,\n    \"message\": \"Resource '/repo/file.txt' is locked (requested by tool 'write_file')\",\n    \"data\": {\n      \"resource_key\": \"/repo/file.txt\",\n      \"held_by\": \"a1b2c3d4...\",\n      \"expires_at\": 1735689600000\n    }\n  }\n}\n```\n\n`-32600`\n\nis exported as `RESOURCE_LOCKED`\n\n. `onConflict: 'warn'`\n\nskips the block entirely and forwards the call, attaching `lock.conflict_warning`\n\n, `lock.resource_key`\n\n, and `lock.held_by`\n\nto the OTel span instead — useful for observing contention before deciding to enforce it.\n\nResource keys that look like filesystem paths — absolute, `~`\n\n-prefixed, relative (`./`\n\n, `../`\n\n), or a Windows drive letter — are canonicalized via `fs.realpathSync`\n\nbefore being used as a lock key. That means the same real file locks consistently whether it's referenced by a relative path from one project directory, an absolute path, or a symlink pointing at it from somewhere else.\n\nThe default lock store is a local SQLite file at `~/.config/heimdall/locks.db`\n\n— zero configuration required, and it's the same store used by the Claude Code hook, the OpenCode plugin, and the proxy's own `LockInterceptor`\n\n. For multi-machine coordination — multiple proxy instances, or a fleet of agents on different hosts sharing the same underlying resources — Postgres and MySQL backends are available:\n\n```\nheimdall-mcp --store sqlite://./traces.db --lock-store postgres://user:pass@host/db -- node server.js\n```\n\n`'write'`\n\nmode. The storage interface defines `LockMode = 'read' | 'write'`\n\n, but `LockRuleSchema`\n\nhas no `mode`\n\nfield yet, and `LockInterceptor`\n\nhardcodes `'write'`\n\non every acquisition. Two calls that are conceptually read-only still serialize against each other if they share a lock rule.`Bash`\n\n.`PostToolUse`\n\n/ `tool.execute.after`\n\n) to release the lock immediately when the call finishes. Release happens only via TTL expiration (default 30s) — not a bug, a known gap.`init --hooks opencode`\n\nregistration are unverified end-to-endTTL itself deserves one more sentence: it's a backstop for crashed or hung holders, not a per-call timeout. If a process holding a lock dies before releasing it, the lock would otherwise block that resource forever; once the TTL elapses, a new caller can acquire it. Release is idempotent — if the original holder later calls release after its lock already expired and was reacquired by someone else, that stale release is a silent no-op.\n\n```\nnpm install -g @cardor/heimdall-mcp\nheimdall-mcp init --hooks claude-code\nheimdall-mcp init --hooks opencode\n```\n\n*Built by @enmanuelmag for @cardor. Feedback and issues welcome on GitHub.*", "url": "https://wpnews.pro/news/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks", "canonical_source": "https://dev.to/enmanuelmag/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks-4mm5", "published_at": "2026-07-16 01:19:52+00:00", "updated_at": "2026-07-16 01:35:39.130171+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["Heimdall MCP", "Claude Code", "OpenCode", "Enmanuel Mag"], "alternates": {"html": "https://wpnews.pro/news/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks", "markdown": "https://wpnews.pro/news/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks.md", "text": "https://wpnews.pro/news/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks.txt", "jsonld": "https://wpnews.pro/news/only-one-crosses-the-bridge-at-a-time-heimdall-mcp-adds-resource-locks.jsonld"}}