Lang on Rock
Guides

MCP server

Six tools for Claude Code, Cursor, or anything else that speaks MCP, and three database tools on request.

langonrock mcp "okf://$PWD/data?tenant=acme"

The MCP server speaks stdio on top of a Connection, so it works against an embedded path, a local daemon or a remote server without knowing which.

Registering it with Claude Code

claude mcp add langonrock -- langonrock mcp "okf:///abs/path/to/data?tenant=acme"

Everything after -- is the command being registered, and the path in the connection string has to be absolute. The client starts the process, and its working directory is not yours.

Any connection string works, so point it at a running daemon instead and every agent invocation shares one process with warm indexes rather than paying cold start:

claude mcp add langonrock -s project -- langonrock mcp "okf+unix:///tmp/okf.sock?tenant=acme"

-s project writes the registration to .mcp.json in the repository, so committing it hands the same knowledge to everyone who clones. That only works if the connection string resolves on their machines too, which in practice means a socket or a server rather than a path.

The six tools

ToolInputOutput
manifestbundle?The manifest as TSV
searchquery, k?, bundle?Ranked manifest rows plus a pos cell, never bodies
getids[], section?, offset?, limit?, find?Framed slices, one @@ id block each
snapshotnoneThe current digest
writebundle, path, content, replaces?The commit's snapshot and concept count, plus that file's warnings
deletebundle, path, replacesThe commit's snapshot and concept count

k is capped at 50 by the schema. get requires at least one id, and reports anything it could not find as a trailing @@ missing block rather than failing the call.

get returns at most 15,000 characters per concept by default. A partial slice frames itself as @@ id [start..end of total], so the model continues with offset or raises limit when it truly needs more at once. find takes a literal case-insensitive phrase and answers with a window around the first occurrence plus the offset of every one — the cheap way to answer "where does the text say this" without paying for the document.

When there is no phrase to quote, search itself points the way: every direct hit ends in a pos cell, the character offset where the query's words cluster densest in that concept's body, - when they only match its manifest row. The follow-up is get with {offset: pos, limit: 2000} — a located passage instead of a capped document, for descriptive questions that have no literal phrase for find.

Six verbs by default, because every tool definition costs tokens in the client's system prompt. The four read tools measure 1,122, write adds 412 and delete 207, so a session that never touches knowledge still pays 1,741. manifest and search narrow, get fetches, snapshot invalidates, write persists, delete retracts. The agent API records what each one had to earn.

The manifest description carries the tenant's own advice

At startup the server measures the manifest and appends one sentence to the manifest tool's description: prefer search when the manifest costs more than twenty times one estimated search result, read it whole when it does not. The crossover comes from the benchmarks, the arithmetic comes from the manifest itself, and the sentence is deterministic — same manifest, same advice. The advice is read once per client session, so a tenant that changes shape under a running server keeps the old sentence until the next session; corpus shape moves slowly enough for that to be fine.

The manifest is exposed twice

As a tool for every client, and as an MCP resource at okf://manifest for clients that preload resources into context.

The resource path is the one that delivers the central claim of the design. A preloaded manifest lands in the cacheable prompt prefix, where it costs roughly a tenth as much on every later turn. A manifest arriving mid-conversation as a tool result does not.

okf://manifest    mimeType: text/tab-separated-values

Tool descriptions state when to call

Not just what the tool does. manifest says "call this before anything else whenever you need to know what knowledge exists" and "never guess an id". get says "pass every id you need in one call rather than calling repeatedly; the batch costs one round trip regardless of size".

A description that only describes gets under-triggered. That is a real effect and worth the extra tokens in the schema.

The manifest description also explains the status column, so an agent knows that a cell other than - means the concept is deprecated, draft, or stale — the reader demotes a concept past its stale_after date at read time — and that it should say so if it uses one anyway.

The session an agent should have

  1. Read the manifest, ideally from the cached prefix rather than by calling the tool.
  2. Pick ids from the rows it already holds.
  3. Call get once with all of them — with section when it only needs the schema, with find when it only needs a quoted passage, with offset: pos from a search hit when the passage was described rather than quoted.

Two round trips, and the second one is batched. On a large tenant, replace step 1 with search, or with manifest narrowed to a bundle — the manifest tool's own description says which.

Writing commits

write and delete each commit one document as a one-change transaction. When the tool answers, the commit is durable and the change is already visible to manifest, search and get on the very next call. There is no recompile to wait for and no watcher in the path.

The response carries the commit's snapshot digest, the concept count, and the compiler's warnings for the file just written — a missing type, a link resolving to nothing — so an agent learns its frontmatter was wrong at the moment it wrote it.

The precondition, for a caller with no hash

Replacing a concept needs the hash of the version being replaced, or two editors silently lose each other's work. A model has no hash. Fetching one first would cost a round trip on every write, and a separate read-the-hash tool would cost a whole definition in every session's prompt, so the refusal carries what the retry needs:

write {bundle: "inbox", path: "idea.md", content: "..."}
  → wrote inbox/idea.md (hash 955b8387d3d7…), snapshot 91f286abe58a…, 5 concepts

write {bundle: "inbox", path: "idea.md", content: "...more"}
  → concept already exists: re-read the concept and retry with its new hash
    retry with replaces: "955b8387d3d7…"

write {bundle: "inbox", path: "idea.md", content: "...more", replaces: "955b8387d3d7…"}
  → wrote inbox/idea.md (hash dd1e7a0cf242…), snapshot 7004a51382b3…, 5 concepts

Creating costs one call, because omitting replaces is what asserts the concept is new. Replacing costs two the first time. Neither can silently overwrite.

delete works the same way without the create case, so its hash is not optional and a deletion is always two calls. Called without one, it answers deleting needs the hash of the version being removed and names the hash to retry with. Aimed at a concept that is not there, it answers concept does not exist and offers no hash, rather than inviting a retry that cannot succeed. Removing the last concept of a bundle removes the bundle.

A tenant is created by the first write

Asking an agent to persist something for a tenant that does not exist yet is an ordinary first request. write creates the tenant's database with that one document as its first revision, with no sources.json entry, no restart and no setup. Only the tenant the connection was already scoped to is ever created, so this reaches nothing the caller could not already write.

A tenant from the folder store writes the old way

A tenant that still has its folder-store layout, a current pointer and no HEAD, has not been migrated. There write and delete edit the source folder registered in sources.json and recompile, and a tenant with no registration is refused rather than replaced by an empty folder. Migrate it to get commits and history.

Ids are the shortest unambiguous form of their path, so creating a file can rename a concept nobody touched. The tool description says so, and an agent should re-read the manifest after writing rather than reuse ids it saw before.

Database tools

langonrock mcp "$DSN" --database-tools

The flag, or { databaseTools: true } as the third argument of createMcpServer, adds three more tools for agents that need batches and history:

ToolInputOutput
transactchanges[] (1–1,000 write or delete), expectedRevision?The revision, its snapshot, counts and diagnostics, as JSON
historybefore?, limit? (1–100)A page of revisions, newest first, as JSON
restorerevision, expectedRevisionThe new revision, as JSON

No change in a transact batch commits if any precondition in it fails. Every hash in these schemas must be 64 lowercase hex characters, and the server's schema validation refuses anything else before the call reaches the store:

transact {changes: [{operation: "delete", bundle: "sales", path: "tables/orders.md", replaces: "abc"}]}
  → MCP error -32602: Input validation error: Invalid arguments for tool transact:
    Invalid string: must match pattern /^[a-f0-9]{64}$/ at changes[0].replaces

restore publishes an older revision's complete source state as a new commit and never rewrites history; expectedRevision must match the current head. History and restore covers paging and what a pruned revision means.

They are off by default for the same reason the default set is six: together the three definitions add about 510 tokens to every session, whether or not the model ever commits a batch.

An indeterminate commit is not a refusal

When a write fails after the commit point, the error says the outcome is indeterminate and names the revision to inspect. No retry hash is offered for it. Read history and decide from what is there before retrying. Durability covers the contract.

Debugging it

The stdio transport owns stdout. Anything else written there corrupts the JSON-RPC framing, so all diagnostics go to stderr:

langonrock mcp serving okf:///abs/path/to/data?tenant=acme over stdio

A tool that throws returns its message as an error result rather than killing the connection, so a bad tenant or a missing store shows up in the client as a readable failure.

On this page