agent-locks
Leverages git's common-dir mechanism to store lock files in the shared .git directory, allowing multiple worktrees of the same repository to coordinate without polluting git history.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@agent-locksClaim src/index.ts for bug fix"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
agent-locks
A filesystem-based, database-free MCP server that lets AI coding agents (Claude Code sessions, subagents, or anything else speaking MCP) claim work, see what other agents are doing, avoid stepping on each other's files, and leave a readable log of what happened — across every git worktree of the same repository, without ever polluting that repo's own git history.
No database. No server to run. No credentials. Just markdown files under a directory that git structurally can never track.
Why this exists
When multiple agents work in parallel on different git worktrees of the same repository, they have no shared, low-ceremony way to say "I'm working on these files right now" or "here's what I did and why." agent-locks fills that gap with one idea: store lightweight lock files under the repository's shared .git directory, which every worktree of that repository can see, and which git itself can never accidentally commit.
Related MCP server: session-coord-mcp
The git-common-dir trick (the crux of the whole design)
Every worktree of a git repository — the original checkout and every git worktree add-created linked worktree — shares exactly one real .git directory. A linked worktree's own .git is not a real git directory at all; it's a plain file containing a pointer back to the shared one:
$ cat /path/to/linked-worktree/.git
gitdir: /path/to/main-repo/.git/worktrees/linked-worktreeThis means two different git commands give two different answers, and only one of them is useful here:
Command | From the main worktree | From a linked worktree |
|
|
|
|
|
|
agent-locks resolves its storage location by running git rev-parse --git-common-dir (via child_process, never cached, see below) and storing locks at:
<git-common-dir>/agents-locks/
├── 2026-07-17T18-45-12-hindsight-route-tests.md # active locks live directly here
├── 2026-07-17T09-12-03-oauth-cleanup.md
└── done/ # finished locks are moved here
└── 2026-07-16T22-01-00-fix-flaky-test.mdVerified empirically (see src/__tests__/git.test.ts): a real git init + git worktree add pair produces the identical --git-common-dir from both worktrees, while --git-dir genuinely differs. This is not an assumption — it's exercised by an automated test that creates a real temporary git repo and a real linked worktree on every test run.
Why this can never be committed to the repo you're working on
agents-locks/ lives under .git itself, not inside the tracked working tree. This is not a .gitignore entry (a .gitignore rule wouldn't even apply here — the directory isn't part of the working tree git tracks at all) — it's structural: git's index and working-tree model have no concept of a path under .git/ as something that can be staged. Empirically verified (also in src/__tests__/git.test.ts):
$ git add .git/agents-locks/some-lock.md
$ echo $?
0 # no error...
$ git status --porcelain
# ...but nothing was actually staged
$ git ls-files | grep some-lock
# ...and it never appears in the indexgit add on a path under .git/ is a silent no-op, not an error — there's no error message an agent could work around or accidentally suppress. The file structurally cannot enter the index.
How the path is resolved — freshly, every single call
Every tool implementation calls resolveLocksRoot() (src/git.ts) at the start of its own handler, which runs git rev-parse --git-common-dir with cwd set to the server process's own current working directory (process.cwd()) at that exact moment — never cached across calls, never resolved once at server startup. There is no protocol-level or environment-variable mechanism for a stdio MCP server to learn "which worktree is this particular tool call morally about" (see the Claude Code launch mechanics section below) — the server's own cwd at call time is the only signal available, and re-resolving it fresh every call costs one cheap subprocess spawn while removing any risk of relying on a stale assumption.
How Claude Code launches this server
Claude Code's .mcp.json/claude mcp add configuration for a stdio server has no cwd field. A spawned stdio server simply inherits Claude Code's own current working directory at the moment it's launched (standard child_process.spawn behavior when no explicit cwd is given) — i.e., whatever directory the claude session itself was started from, which for a worktree-rooted session is that worktree's own directory. This is exactly what this tool needs: two Claude Code sessions rooted in two different worktrees of the same repo will each spawn their own agent-locks process with a different process.cwd(), and both will resolve to the same agents-locks/ directory via --git-common-dir.
Claude Code does expose one environment variable to spawned stdio servers, CLAUDE_PROJECT_DIR — but this project deliberately does not use it. Per Claude Code's own docs, CLAUDE_PROJECT_DIR is "the stable project root" that "doesn't change when you add or remove working directories mid-session." That stability is exactly wrong for this tool: if a user works from a linked worktree, CLAUDE_PROJECT_DIR would likely still point at (or be defined relative to) the original/main project root rather than the worktree the session is actually rooted in, defeating the entire per-worktree design. Using the server process's own inherited cwd instead is what actually varies correctly across worktrees.
File format
---
id: 2026-07-17T18-45-12-hindsight-route-tests
agent_id: subagent-4f2a
parent_agent_id: session-abc123
status: active
created: 2026-07-17T18-45-12
updated: 2026-07-17T18-45-12
scope:
- backend/src/hindsight/**
---
# Add hindsight route tests
- [x] Write route unit tests
- [ ] Write integration test
## Notes
- Started after checking for conflicts with the oauth-cleanup lockParsed and serialized by src/lock/markdown.ts using gray-matter for the frontmatter/body split, plus a small hand-written parser/serializer for the specific body shape (title heading, checklist, Notes section) that this project owns entirely — calling agents never write raw markdown; they pass structured tool arguments and this module is the only place that turns them into (or back out of) the file format.
Timestamp format
Single format used consistently in the filename prefix, the frontmatter id, and the created/updated fields: YYYY-MM-DDTHH-MM-SS, in UTC — e.g. 2026-07-17T18-45-12.
Dashes instead of colons in the time portion, because
:is awkward-to-forbidden in filenames on some filesystems (notably Windows/NTFS). The date portion's dashes were never a problem; they're kept purely for readability.Seconds precision (not just hours:minutes) keeps same-second collisions rare without needing milliseconds. On the rare occasion two locks with the same title are created in the same second,
lock_createappends a numeric suffix (-2,-3, ...) to guarantee a unique file — this is a safety-net fallback, not the primary naming scheme (the design is deliberately sequence-number-free otherwise).UTC (not local time) so timestamps from agents on different machines in different timezones are directly, correctly comparable.
Fixed-width, zero-padded fields in a consistent order mean plain string sorting of filenames or
ids is equivalent to chronological sorting.
The id frontmatter field is, by design, exactly the filename minus .md — e.g. filename 2026-07-17T18-45-12-hindsight-route-tests.md has id: 2026-07-17T18-45-12-hindsight-route-tests. Keeping these byte-identical (rather than letting the filename and the id field drift independently) removes an entire class of "which one is authoritative" bugs.
Filename
{timestamp}-{kebab-case-title}.md — purely chronological, no sequence numbers by design (these files are ephemeral coordination artifacts, not a numbered decision log).
The 5 MCP tools
All five are implemented in src/server.ts; the actual filesystem logic lives in src/lock/store.ts.
lock_query
Lists locks. Hard requirement, enforced and tested (src/__tests__/store.test.ts): when status is omitted, done locks are excluded — you see current work, not history, by default.
{ "name": "lock_query", "arguments": {} }
{ "name": "lock_query", "arguments": { "status": "all", "text": "oauth" } }
{ "name": "lock_query", "arguments": { "scope": "backend/src/oauth/client.ts" } }Returns Array<{id, title, status, percentComplete, scope, agent_id, parent_agent_id}>. percentComplete is the ratio of checked to total tasks (a lock with zero tasks reports 100).
lock_check_conflict
Purely informational — never blocks, never vetoes, has no side effects. Returns any active locks whose scope glob-overlaps the patterns you pass in; you decide what to do with that information.
{ "name": "lock_check_conflict", "arguments": { "scope": ["backend/src/oauth/**"] } }lock_create
{
"name": "lock_create",
"arguments": {
"title": "Fix flaky OAuth callback test",
"scope": ["backend/src/oauth/**"],
"tasks": ["Reproduce the flake", "Add a deterministic fixture", "Confirm 20x green"],
"agent_id": "subagent-4f2a"
}
}Returns {id, filePath}.
lock_update
{ "name": "lock_update", "arguments": { "lock_id": "2026-07-17T18-45-12-fix-flaky-oauth-callback-test", "task_text": "Reproduce the flake", "done": true, "note": "Repro'd via 50x loop with -t 30s" } }task_text must match an existing task exactly (chosen deliberately over fuzzy/partial matching — it's the unambiguous, predictable default). A non-matching task_text returns a real MCP tool error (isError: true) listing the lock's actual task texts, never a silent no-op.
lock_finish
{ "name": "lock_finish", "arguments": { "lock_id": "2026-07-17T18-45-12-fix-flaky-oauth-callback-test", "summary": "Fixed by adding a deterministic clock fixture; merged in PR #42." } }Moves the file from agents-locks/ to agents-locks/done/, sets status: done. Errors clearly (not silently) if the lock doesn't exist, or already exists but is already done.
Honest agent_id / parent_agent_id semantics
Claude Code does not expose any session id to a stdio MCP server subprocess — not via environment variable, not via any MCP initialize parameter (the spec's initialize params are only protocolVersion, capabilities, clientInfo), and there is no documented mechanism for a subagent's MCP server process to learn its parent session's id either.
agent_id and parent_agent_id on lock_create are therefore plain optional strings that the calling agent supplies only if it happens to already know one from its own context (some orchestration harnesses hand a subagent an explicit id when dispatching it). This server has no way to detect either value and never fabricates one — both default to null when omitted. Every tool description says this plainly.
No database, no in-memory cache
The markdown files are the entire source of truth. Every tool call reads whatever is currently on disk at that moment — there is no cached lock list, no in-memory index, and no assumption that this is the only server process for a given repo.
Glob overlap heuristic (lock_check_conflict, and lock_query's scope filter)
There's no exact, general algorithm for "do these two glob patterns ever match a common file" that doesn't require enumerating the filesystem — and even that only answers it for files that exist right now. src/lock/globOverlap.ts uses a static-prefix heuristic, deliberately biased toward false positives over false negatives, because this tool is informational-only: a false positive just means an agent double-checks something that was actually fine; a false negative would silently hide a real conflict.
Exact match → overlap.
Compare each pattern's literal prefix (everything before the first
* ? [ ] { } ( ) !). If one prefix is a raw-string prefix of the other → overlap.src/foo/**vssrc/foo/bar.ts→ overlap (correct: the first pattern matches that exact file).src/**vssrc/foo/**→ overlap (correct: both can match files undersrc/foo/).packages/foo/**vspackages/bar/**→ no overlap (correct: different packages).A pattern whose first character is itself a wildcard (
*.ts,**+/*.test.ts) has an empty prefix, which trivially prefixes everything — so such patterns are conservatively reported as overlapping with anything in scope. Intentional over-inclusion, not a bug.
Fallback: if the prefixes disagree, also check (via
minimatch) whether either pattern, treated as a literal path string, is matched by the other pattern's glob. This specifically matters for extglob syntax (+(foo|bar),@(foo|bar),!(foo|bar)) — e.g.src/+(foo|bar)/**'s naive static prefix is"src/+"(only the(is treated as a wildcard-start, not the+before it), which does not raw-string-prefix"src/foo/util.ts", so the prefix stage alone would wrongly say "no overlap"; the realminimatchcheck in the fallback catches it.
Known, documented gap
Filesystem case-sensitivity is not modeled. Src/** and src/foo.ts are reported as non-overlapping (matching is case-sensitive, per minimatch's default), but on a case-insensitive filesystem (default macOS, default Windows) these could refer to the exact same real file. This is not special-cased, because "is this filesystem case-sensitive" isn't knowable from the pattern strings alone, and the case-sensitive assumption matches the Linux dev environments this tool targets. Pinned down explicitly by a test in src/__tests__/globOverlap.test.ts so a future reader knows this is a deliberate, accepted limitation rather than an untested edge case.
(There's also a documented, deliberately-accepted over-inclusion case for {brace,expansion} patterns — see the comments in globOverlap.ts and its test file for the reasoning; that direction is considered safe, not a gap, given this tool's informational-only nature.)
Installing this as an MCP server in Claude Code
Node/TypeScript, not Python — uvx (which runs Python packages via uv) does not apply here. The correct launcher is pnpm dlx (pnpm's equivalent of Python's uvx / Node's npx, for running a package's binary without a permanent global install).
Once published to npm, add it with:
claude mcp add --transport stdio agent-locks -- pnpm dlx agent-locksor as a .mcp.json / ~/.claude.json entry:
{
"mcpServers": {
"agent-locks": {
"type": "stdio",
"command": "pnpm",
"args": ["dlx", "agent-locks"]
}
}
}Before this package is published to npm, install directly from GitHub instead (pnpm dlx resolves the exact same way whether the package spec is a registry name or a github: spec — verified directly, see "Verification" below):
claude mcp add --transport stdio agent-locks -- pnpm dlx github:luohoa97/agent-locks{
"mcpServers": {
"agent-locks": {
"type": "stdio",
"command": "pnpm",
"args": ["dlx", "github:luohoa97/agent-locks"]
}
}
}Once added, Claude Code will always launch it with command: pnpm, args: [dlx, ...] — no manual build step, no cloning required on the user's part; pnpm dlx handles fetching and installing the package on demand.
Packaging: why dist/ is committed to this repo
Normally a compiled dist/ directory has no place in git. Here it's committed deliberately: pnpm dlx github:... (the pre-npm-publish install path above) clones the full repository and runs the package as-is — there is no npm publish-time "files" filtering step for a git-based install, and pnpm's script-execution security model means a prepare/postinstall build step is not guaranteed to run automatically for a fresh dlx invocation. Committing the already-built dist/index.js means the pnpm dlx github:... flow works with zero assumptions about lifecycle-script execution. Once this package is published to npm, the packed tarball (governed by "files": ["dist"] in package.json) is what consumers actually receive, and the committed copy becomes a convenience for the interim git-based flow — kept in sync by running pnpm run build before every commit that touches src/ (the pretest script also rebuilds automatically before every pnpm test run, so a stale dist/ is caught by CI/local testing rather than silently drifting).
Development
pnpm install
pnpm run typecheck # tsc --noEmit
pnpm test # rebuilds dist/ first (pretest hook), then runs vitest
pnpm run build # bundles src/index.ts -> dist/index.js via tsup (shebang + executable bit preserved)
pnpm run dev # run directly from source via tsx, no build step (for local iteration)What's tested (src/__tests__/)
timestamp.test.ts— timestamp formatting and slug generation.markdown.test.ts— frontmatter + body round-tripping (parseLockFile(serializeLockFile(x)) === x), including the exact documented file shape.globOverlap.test.ts— the overlap heuristic, including the extglob fallback case and the documented case-sensitivity gap.store.test.ts— the full lock lifecycle (create → update → finish), the hard "done excluded from default query" requirement, exact task-text matching (with a clear error on mismatch, never a silent no-op), and conflict-checking.git.test.ts— creates a real temporary git repository and a real linked worktree (via actualgit init/git worktree addsubprocess calls) and provesresolveLocksRoot()returns the identical path from both, that--git-dirwould have differed, and that a path under.git/agents-locks/can never enter git's index.e2e.test.ts— spawns the actual compileddist/index.jsas a real subprocess (via the MCP SDK's ownClient+StdioClientTransport, exactly how Claude Code itself talks to an MCP server) and drives real JSON-RPC round trips:initialize,tools/list, and a fulllock_create→lock_query→lock_update→lock_finish→lock_querycycle against the real filesystem, plus a real tool-error round trip for a badtask_text.
Verification performed for this project (not just unit tests)
In addition to the automated suite above, the following were run manually against the actual built artifacts:
node dist/index.jsspawned directly and driven throughinitialize→lock_create→lock_queryvia the MCP SDK's client (this is whate2e.test.tsalso automates).pnpm dlx <local tarball produced by \npm pack`>— spawned exactly the way a real consumer's package manager would install and run it, driven through the sameinitialize→tools/list→lock_createround trip, and the created lock file was independently confirmed on disk under a real temporary git repo's.git/agents-locks/`.pnpm dlx github:luohoa97/agent-locks(after this repo was pushed) — the actual pre-npm-publish install command from this README, run for real against the pushed GitHub repository, exercising the identicalcommand: pnpm, args: [dlx, ...]shape a Claude Code config would use.
License
MIT
This server cannot be installed
Maintenance
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/luohoa97/agent-locks'
If you have feedback or need assistance with the MCP directory API, please join our Discord server