Run ynm as a server: one process in front of one bare repository, speaking MCP over HTTP. Agents connect with nothing but a URL and a token; they never need git.
The tutorial has two parts. In part 1 the server takes one static token: you start it, check its health, are refused without the token, make MCP calls with curl, and see the result land in the repository. In part 2 people sign in through an identity provider instead, the way a shared server is run: each person gets their own token, a client finds where to sign in by itself, and the server refuses a token that lacks the access it needs. Last, your own ynm mounts the server as its shared store, so personal memory stays on your machine beside it.
ynm is on your PATH, and curl. Prepare the sandbox and the store the server will front, a
bare repository with ynm initialised in it:
rm -rf /tmp/ynm-tutorial
mkdir -p /tmp/ynm-tutorial
export YNM_HOME=/tmp/ynm-tutorial/home
export YNM_USER=tutorial
export YNM_NO_CLAUDE_CLI=1
cd /tmp/ynm-tutorial
ynm init --bare /tmp/ynm-tutorial/store.git
ynm init --cwd /tmp/ynm-tutorial/store.gitExpected: created bare memory repo /tmp/ynm-tutorial/store.git (anchor <12-hex sha>), then an
initialised <path> report whose anchor is the same commit. A bare repo has no code to anchor
to, so init --bare creates a root commit for the purpose. It has no work tree for an agent to
open either, so the report has no client lines: ynm init configures agent clients only in a
checkout. Clients reach this store over HTTP instead, as below and in
Connect a client over HTTP.
A static token is the quickest way to try the server, and fine for a demo. It is a shared secret:
everyone who has the token is the same caller, recorded as token:static, so the server can't tell
them apart. Part 2 gives each person an identity of their own.
--http switches from stdio to Streamable HTTP. --no-personal mounts no personal store, which
is what you want on a shared server. --token is a static bearer token, fine for a demo. The
server goes in the background, and we wait until it answers:
ynm serve --http --port 3999 --token demo --no-personal --cwd /tmp/ynm-tutorial/store.git > /tmp/ynm-tutorial/serve.log 2>&1 &
echo $! > /tmp/ynm-tutorial/serve.pid
until curl -sf http://localhost:3999/health > /dev/null; do sleep 0.5; done
cat /tmp/ynm-tutorial/serve.logExpected: the server's own log, two lines.
ynm-mcp listening on http://localhost:3999/mcp (health: http://localhost:3999/health)
ynm-mcp auth: bearer
/health is outside authentication, so a load balancer can poll it:
curl -s http://localhost:3999/healthExpected: one line of JSON. It names the auth mode and reports the background workers, which have not run since we did not ask for any.
{"status":"ok","name":"ynm","auth":"bearer","scheduler":{"dreamRuns":0,"syncRuns":0,"lastDreamAt":null,"lastSyncAt":null,"lastError":null,"lastDream":null}}
Everything else answers only to the token:
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3999/mcp -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3999/mcp -H 'Authorization: Bearer wrong' -H 'Content-Type: application/json' -d '{}'Expected: 401 twice. The response carries a standard WWW-Authenticate: Bearer challenge, with a
description of "Missing Authorization header" in the first case and "invalid token" in the
second, so a real MCP client knows to authenticate.
The endpoint is /mcp. Each request is a JSON-RPC message sent as a POST, and the answer
comes back as a server-sent event whose data: line holds the JSON. This server is stateless,
so there is no session to open: initialise once to see what it offers, then call tools.
curl -s -X POST http://localhost:3999/mcp -H 'Authorization: Bearer demo' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' | grep '^data:' | cut -c7-Expected: one line of JSON whose result names the server ("serverInfo":{"name":"ynm","version":"<version>", ...}),
its capabilities (tools, resources and prompts) and an instructions string. It tells an agent
that ynm is the user's memory, to use memory_remember rather than any built-in memory when
asked to remember something, and to read memory_recall or memory_context before answering
about the user or the project. Its last line begins
- Level: distributed only: everything stored on this server is shared with everyone who uses it,
because this server mounts no personal store, and goes on to tell the agent to ask before
sharing. A client connected with nothing but the URL gets this text and the tool descriptions,
and nothing else.
Now write a memory the way an agent would by default, naming no level:
curl -s -X POST http://localhost:3999/mcp -H 'Authorization: Bearer demo' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memory_remember","arguments":{"type":"semantic","content":"The hosted store answers on port 3999."}}}' | grep '^data:' | cut -c7-Expected: a result with "isError":true and nothing stored:
{"result":{"content":[{"type":"text","text":"nothing stored: this store has no personal level, so a memory stored here is shared with everyone who uses it. To share it, ask the user first, then set level to distributed. To keep it private, store it in a local ynm instead."}],"isError":true},"jsonrpc":"2.0","id":2}
Memory is personal by default, and this server has nowhere personal to put it. Sharing is a
choice, so the call has to make it: "level":"distributed". Write it that way, then read it back:
curl -s -X POST http://localhost:3999/mcp -H 'Authorization: Bearer demo' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"memory_remember","arguments":{"type":"semantic","level":"distributed","content":"The hosted store answers on port 3999."}}}' | grep '^data:' | cut -c7-
curl -s -X POST http://localhost:3999/mcp -H 'Authorization: Bearer demo' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"memory_recall","arguments":{"text":"hosted store"}}}' | grep '^data:' | cut -c7-Expected: two lines of JSON. The first is the result of memory_remember: its
structuredContent holds a new memoryId, "mount":"project" and a revision sha. The second
is memory_recall, whose structuredContent.data is an array with one hit, the memory you just
wrote, with "level":"distributed" and "namespace":"common": a shared token vouches for no one,
so there is no person to file it under, and the hit names no author. Any MCP client library, such as
@modelcontextprotocol/client with its HTTP transport, does the same thing with the framing
handled for you.
The server is the only writer, but the store is an ordinary git repository. Read it from the CLI and from git itself:
ynm list --level distributed --cwd /tmp/ynm-tutorial/store.git
git -C /tmp/ynm-tutorial/store.git for-each-ref --format='%(refname)'Expected: the one memory, then two refs, the anchor branch and the shard the server wrote:
<id> semantic distributed common The hosted store answers on port 3999.
refs/heads/main
refs/notes/ynm/distributed/common/semantic/<yyyy-mm>
A developer clone of this repository would fetch that ref with ynm sync, exactly as in
tutorial 6. The agent that wrote it needed no git.
Who does the record say wrote it? The raw record keeps the writer in provenance.actor:
ynm export --level distributed --cwd /tmp/ynm-tutorial/store.git | grep -o '"actor":"[^"]*"'Expected:
"actor":"token:static"
Not you, and not the server's own user (tutorial here): the static token. Everyone who writes
with this token is recorded the same way, so there is no per-person audit trail. For that, the
server needs an identity provider, as in part 2.
kill $(cat /tmp/ynm-tutorial/serve.pid)
sleep 1
curl -s -m 2 http://localhost:3999/health || echo "server stopped"Expected: server stopped.
A shared server should know who is calling. Here the server verifies tokens issued by an identity
provider, and tells clients where to get one. The repository ships a local identity provider for
this, Keycloak, with two people already in it: alice (password alice) and bob (password
bob). It needs Docker and a checkout of the repository. To run this part, set
DOCKER_HOST_AVAILABLE=1 in your shell and point YNM_REPO at the checkout, for example with
export YNM_REPO=$HOME/src/ynm, using the path of your own checkout. The steps run the
repository's make targets from there while your shell stays in the sandbox.
make -C "$YNM_REPO" keycloak
make -C "$YNM_REPO" keycloak-checkExpected: keycloak ready: http://localhost:8180/realms/ynm, then a list of ok lines and
finally keycloak gives ynm everything sign-in needs. The check proves the provider publishes
its signing keys, lets a client register itself, issues tokens for http://localhost:3000/mcp
naming the person who signed in, and gives bob read access without write access when that is all
he asks for. The first start downloads the Keycloak image and can take a minute.
Three settings point the server at the provider: where its signing keys are, who issues the
tokens, and the address tokens must be issued for. YNM_REQUIRED_SCOPES makes every request
need both read and write access:
YNM_JWKS_URL=http://localhost:8180/realms/ynm/protocol/openid-connect/certs \
YNM_JWT_ISSUER=http://localhost:8180/realms/ynm \
YNM_JWT_AUDIENCE=http://localhost:3000/mcp \
YNM_REQUIRED_SCOPES=memory:read,memory:write \
ynm serve --http --port 3000 --no-personal --cwd /tmp/ynm-tutorial/store.git > /tmp/ynm-tutorial/serve.log 2>&1 &
echo $! > /tmp/ynm-tutorial/serve.pid
until curl -sf http://localhost:3000/health > /dev/null; do sleep 0.5; done
curl -s http://localhost:3000/healthExpected: the health line now names the mode jwt:
{"status":"ok","name":"ynm","auth":"jwt","scheduler":{"dreamRuns":0,"syncRuns":0,"lastDreamAt":null,"lastSyncAt":null,"lastError":null,"lastDream":null}}
Ask without a token, then read the document the refusal points to:
curl -s -i -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -d '{}' | grep -i -E '^HTTP|^www-authenticate'
curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcpExpected: a 401, and a challenge that names the access needed and where to read about signing in:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="memory:read memory:write", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"
{"resource":"http://localhost:3000/mcp","authorization_servers":["http://localhost:8180/realms/ynm"],"scopes_supported":["memory:read","memory:write"],"bearer_methods_supported":["header"]}
The last line is that document: tokens must be issued for http://localhost:3000/mcp, by the
identity provider at http://localhost:8180/realms/ynm, with read and write access. That is
everything a client needs to sign a person in by itself.
A client signs a person in through the browser. A script can ask the provider for a token
directly, which make keycloak-token does for alice:
ALICE=$(make -s -C "$YNM_REPO" keycloak-token U=alice)
curl -s -X POST http://localhost:3000/mcp -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"memory_remember","arguments":{"type":"semantic","level":"distributed","content":"Sign-in to the hosted store goes through Keycloak."}}}' | grep '^data:' | cut -c7-
curl -s -X POST http://localhost:3000/mcp -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"memory_people","arguments":{"action":"nickname","nickname":"alice"}}}' | grep '^data:' | cut -c7-
ynm list --level distributed --cwd /tmp/ynm-tutorial/store.gitExpected: the memory_remember result, with a new memoryId, "mount":"project" and a
revision sha; the memory_people result, {"person":"<person id>","nickname":"alice"}; then
both memories in the store, the one from part 1 and alice's:
<id> semantic distributed user/<person id> Sign-in to the hosted store goes through Keycloak. (alice)
<id> semantic distributed common The hosted store answers on port 3999.
Alice signed in, so her memory is hers: written as user:<person id>, a ynm id derived from her
sign-in, and filed in her own namespace because she named none. The nickname she set is how the
store shows her. The server's log holds one audit line per request, with her person id, the
client and the tools called, and none of the content.
Bob asks only for read access. This server needs both on every request, so it refuses him:
BOB=$(make -s -C "$YNM_REPO" keycloak-token U=bob S=memory:read)
curl -s -i -X POST http://localhost:3000/mcp -H "Authorization: Bearer $BOB" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"memory_recall","arguments":{"text":"sign-in"}}}' | grep -i -E '^HTTP|^www-authenticate'Expected: a 403, not a 401. Bob is who he says he is, but lacks the access:
HTTP/1.1 403 Forbidden
www-authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="memory:read memory:write", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"
This step needs a person at a browser, so it is not run automatically. With the server still running, add it to Claude Code with nothing but its URL, from a folder of your choice:
claude mcp add --transport http --scope local ynm-signin http://localhost:3000/mcp
claudeExpected: Claude Code starts and says one MCP server needs authentication. Run /mcp, pick
ynm-signin and choose Authenticate. A Keycloak login page opens in the browser; sign in as
alice / alice. The browser says authentication succeeded, and back in Claude Code /mcp
shows ynm-signin connected, with the memory_* tools. Ask Claude what it remembers about
sign-in: it calls memory_recall and finds alice's memory. To remove the server afterwards:
claude mcp remove ynm-signin -s local.
An agent connected to the server directly sees only the shared store. Your own ynm can mount the server instead, as its distributed store: the agent then talks to one ynm, your personal memory stays on your machine, and the server is reached for you, signed in as you. Add the mount to the sandbox's config:
mkdir -p /tmp/ynm-tutorial/home
cat > /tmp/ynm-tutorial/home/config.json <<'EOF'
{ "mounts": [ { "id": "team", "level": "distributed", "provider": "mcp", "url": "http://localhost:3000/mcp" } ] }
EOF
ynm status
ynm recall --text "sign-in"Expected: the status lists your personal store and the team mount, which says why it could not
be read; recall answers from personal memory alone, which is empty, and says what it left out:
ynm <version>
config: /tmp/ynm-tutorial/home/config.json
personal personal git-notes 0 shard(s) index fresh (0) /tmp/ynm-tutorial/home/store.git
team distributed mcp http://localhost:3000/mcp (not signed in: run `ynm login team`)
no matches
shared memory left out: team: not signed in: run `ynm login team`
Being signed out, or offline, never stops ynm: reads go on without the shared store and say so.
Signing in needs a person at a browser, so this step is not run automatically:
ynm login team
ynm remember --type semantic --content "I check sign-in changes against a fresh browser profile."
ynm remember --type semantic --level distributed --content "The hosted store's sign-in is checked before every release."
ynm recall --text "sign-in"
ynm list --level distributed --cwd /tmp/ynm-tutorial/store.git
ynm contextExpected: ynm login opens a Keycloak login page; sign in as alice / alice, and it prints
signed in to team (http://localhost:3000/mcp). The first memory names no level, so it stays
personal: remembered <id> in personal, level personal, namespace user/tutorial. The second asks to
share: remembered <id> in team, level distributed, namespace user/<person id>.
Recall finds three memories in one list, your personal one and the two on the server, each
labelled with where it lives:
0.016 <id> semantic personal I check sign-in changes against a fresh browser profile.
0.016 <id> semantic team Sign-in to the hosted store goes through Keycloak.
0.016 <id> semantic team The hosted store's sign-in is checked before every release.
The listing of the server's store shows only the shared memory, written by alice, beside her earlier one; the personal memory never left your machine. The context block has your memory first and the shared store's after it:
## Memory
- (semantic) I check sign-in changes against a fresh browser profile.
## Shared memory (team)
- (semantic) The hosted store's sign-in is checked before every release.
- (semantic) Sign-in to the hosted store goes through Keycloak.
ynm doctor shows the mount as signed in. The sign-in is kept in auth/team.json under the ynm
home (here the sandbox's), refreshes on its own, and ynm logout team forgets it.
kill $(cat /tmp/ynm-tutorial/serve.pid)
make -C "$YNM_REPO" keycloak-downExpected: the server stops, and Docker removes the Keycloak container and its data, so the next
make keycloak starts from a clean realm.
The repository ships a full demonstration in containers: a store, an agent client that speaks HTTP
with no git, and a developer clone that syncs through the store. It needs Docker and a
checkout of the repository, and it takes a few minutes to build. It runs with
DOCKER_HOST_AVAILABLE and YNM_REPO set as for part 2.
"$YNM_REPO"/infra/docker/demo.shExpected: three headed sections, === agent (HTTP, no git), === clone (git remote = the hosted store) and === store health. The agent writes and recalls over HTTP, the clone syncs and sees
what the agent wrote, and the health line at the end is the same JSON as above, with the
scheduler counters moved. The script removes its containers when it finishes.
cd /tmp
rm -rf /tmp/ynm-tutorial
unset YNM_HOME YNM_USER YNM_NO_CLAUDE_CLI YNM_REPOTo run this for real, see Operate a hosted store, and Signing in from a client for the settings part 2 used.