ProjectBuild a Permission-Aware Knowledge Hub as an MCP Server: the explanation · Module 0: Project overview: a guided tour of Wayfinder · part 2 of 22
Part 2 · Module 0: Project overview: a guided tour of Wayfinder

Module 0: Project overview: a guided tour of Wayfinder

21 min read·
Start here · About 25 minutes · No code · Every screenshot below is the real web console, running locally with Docker on the author's Mac; the Claude Code answers are quoted word for word from a recorded live test.

Wayfinder lets an AI assistant answer questions about a software company's own knowledge: its docs, runbooks, postmortems, architecture decisions (ADRs), issues and pull requests. An engineer connects Claude Code, Cursor or Claude Desktop and asks "why does billing-api retry a failed charge three times?" or "who owns routing-service?". The assistant calls Wayfinder's tools, gets back short passages with numbered citations, and writes the answer from them.

Wayfinder is an MCP server. MCP, the Model Context Protocol, is the standard way an AI client finds and calls tools on another program. Wayfinder has no language model of its own: it searches, filters and cites, and the client's model does the writing. What makes it more than a search box is the permission filter. Every person sees only what their team may read, and the filter runs inside the database query, before any ranking. A restricted passage is never scored for someone who may not read it, never returned, and only counted (the withheld count), never named.

The company in this project is Tidewater Freight, a fictional freight marketplace with 41 people in 6 teams. Its 87 docs, 25 ADRs, 153 issues and 132 pull requests were written for the project, with deliberate traps: a retry policy that was replaced, a request closed as not planned, a stale guide that contradicts a newer runbook, and incident reports that only one team may read.

This page is a guided tour of the finished product. The 20 modules after it take every part apart and show how to build it.

Wayfinder System architecture Legend request flow indexing future, not built 1 step a model AI CLIENTS the model writes the answer BROWSER React + Vite Claude Code Streamable HTTP, OAuth Cursor Streamable HTTP, OAuth Claude Desktop stdio bridge or connector MCP Inspector debugging, quick OAuth Web console search, tools, audit, access Connect page setup, tokens, OAuth consent FRONT DOOR nginx, one public URL nginx SPA, proxy /mcp, OAuth, /.well-known, /api Proxy secret the server refuses requests that skip nginx 1 WAYFINDER SERVER Starlette, mcp 2.3 MCPServer OAuth 2.1 server PKCE, DCR, refresh Token check OAuth, wf_pat_, cookie MCP layer tools, resources Console API /api REST, playground Principal user, team, scopes Rate limit, scope per user, per tool Elicitation confirm create_issue Audit log every call, redacted 2 3 RETRIEVAL no language model on the server Access rules groups from the team Vector list pgvector HNSW, cosine Keyword list full text, IDF weights Knowledge decisions, owners ACL in WHERE before any ranking RRF, k = 60 + ids, owners, recent Replacements with superseded ADRs Citations + withheld count 4 5 6 cited passages go back to the client INDEXING at start and on reindex STORAGE Postgres 17 Seed 403 sources Chunker by heading, 350 bge-small 384 dims, CPU PostgreSQL + pgvector sources, chunks, audit Indexes HNSW, GIN, visibility 7 OPERATIONS evals, Docker, Cloud Run FUTURE Retrieval eval 68 questions, gate Permission gate 0 leaks, 12 users Tests pytest, vitest Containers 3 services, Compose Cloud Run one server instance CI/CD keyless, WIF Connectors wiki, GitHub sync SSO identity provider Stateless MCP more instances
The whole of Wayfinder by layer. Step 1 is an AI client or the browser reaching nginx, 2 the request entering the server, 3 the token, rate limit and scope checks, 4 the caller's groups entering retrieval, 5 the fused ranking, 6 cited passages and the withheld count going back, 7 the indexing path into Postgres. Amber is a model (the client's model writes the answer; bge-small turns text into vectors), blue our code, green the safety parts; dashed purple parts are future work, not built.

Open the full-size architecture diagram

The tour

The tour follows the demo personas through the web console: Noor Rahman, a frontend engineer who joined on 6 October 2026; Fatima Al-Sayed, the payments lead; Priya Natarajan, the VP of Engineering; and Erik Johansson, a security engineer. All names, teams, customers and incidents are invented. The screenshots were taken on 25 October 2026 from the Docker stack, after the security review and the integration pass, so a few traces of that testing show up; the tour points them out.

1. Sign in as a persona

The sign-in page: a headline on the left, a sign-in form and six demo account cards on the right

The sign-in page. The left half states the promise: "Your team's knowledge, inside your AI assistant, filtered to what you may read." Below it are four sample questions, such as "why does billing retry three times?" and "who owns routing-service?". On the right, a plain email and password form, and six demo accounts, each card showing the person's role and their visibility groups: Noor has team:frontend; Fatima has team:payments and restricted:payments-incidents; Erik has team:security, restricted:security and restricted:payments-incidents; Priya has every team group. The note at the bottom gives the demo password, Tidewater-Demo-2026, for local development only, and the suggestion that drives this tour: ask the same question as different people and watch restricted passages disappear.

The groups on those cards are the whole access model. Everyone gets public and their own team. Payments also gets the restricted payments incident reports; security gets those and the restricted security documents; leadership gets every team but not the security restrictions. Groups come from the person's team alone, never from their title (the access model is Module 7).

2. The overview

Overview for Noor: what Wayfinder is, a dark diagram of clients, server and database, four number tiles and recent tool calls

"Good afternoon, Noor." The panel "What Wayfinder is" says it in one line: "It has no language model of its own: your client's model writes the answer." Four bullets name the main ideas: hybrid retrieval with bge-small embeddings and Postgres full text fused with RRF, an access filter that runs in SQL before ranking, superseded decisions that always arrive with their replacement, and one write tool, create_issue, behind a scope and a confirm step. The dark diagram shows the three clients (Claude Code and Cursor over HTTP with OAuth, Claude Desktop over stdio with a token) calling search_knowledge, and the server's path: auth, ACL filter, vector and keyword search, RRF, citations. The tiles count 1 connected client (Claude Code), 52 tool calls today in the audit rows Noor can see, an index of 404 sources, and a search latency of 34 ms at the median and 240 ms at the 95th percentile for today's calls. The table on the right lists Noor's latest tool calls, the newest a create_issue that was denied.

The index tile says 404 sources while the seed has 403. The extra one is billing-api#167, an issue filed through Wayfinder during the integration pass; it is counted under the 154 issues. The "93 docs" on the tile include the three READMEs and three CODEOWNERS files next to the 87 docs.

3. Connect an AI client

The Connect page on the Claude Code tab: a command to add the server, a mock consent screen, and a check command

The Connect page, Claude Code tab. Step 1 is one command, claude mcp add --transport http wayfinder http://localhost:8003/mcp. Step 2: inside Claude Code run /mcp, pick wayfinder and choose Authenticate; the browser opens Wayfinder's consent screen, drawn here as a preview: "Claude Code wants to access Wayfinder as Noor Rahman", with knowledge:read granted and issues:write "not granted to your role". Step 3, claude mcp list, should show the server as Connected. On the right, the server card lists the MCP endpoint, the OAuth metadata address, Noor's groups (public, team:frontend) and her one scope, knowledge:read.

Clients sign in with OAuth 2.1: the client registers itself, opens the consent page in the browser, and receives a token bound to this server. Noor never pastes a password into her editor. Cursor works the same way: its tab shows a short ~/.cursor/mcp.json and says Cursor will show "needs login" until you click it (OAuth and tokens are Module 12; every client's setup is Module 16).

The Claude Desktop tab: an amber warning about public HTTPS URLs, then a personal access token step and a JSON config

The Claude Desktop tab starts with an honest warning: remote connectors in Claude Desktop are reached from Anthropic's cloud, so a localhost address will not work there. Locally, Claude Desktop uses the stdio bridge instead: create a personal access token (scope knowledge:read is enough), then paste a config that runs wayfinder-stdio inside the server container with WAYFINDER_TOKEN=wf_pat_.... The MCP Inspector tab, not shown, gives the debugging route: the Inspector UI with its Quick OAuth Flow, or one CLI command that calls who_owns with a token.

The new personal access token dialog for Fatima, with the name claude-desktop-home, two scopes and 90 days

*Signed in as Fatima, the "New token" dialog: a name (claude-desktop-home), the scopes (knowledge:read always on, issues:write "create issues (with confirm)" as an option, because a payments lead may hold it) and an expiry of 90 days. A personal access token starts with wf_pat_, is shown once, and only its hash is stored. Behind the dialog, Fatima's server card shows three groups, including restricted:payments-incidents, and both scopes.*

4. The same question, as Noor and as Fatima

This is the core idea of the project, so the tour slows down here. The search playground runs exactly the retrieval that search_knowledge runs for a client, as the signed-in person, and shows why each passage matched.

Search as Noor: an amber notice says 2 restricted passages were filtered out, then two passages from the PM-004 public summary

Noor asks "what happened in the duplicate-charge incident?". The amber notice reads: "Filtered out for you: 2 restricted passages from restricted:payments-incidents. They were excluded in SQL before ranking, so they were never scored and their titles are not shown to you or your AI client." The grey line shows the tool call (search_knowledge with the query, as Noor Rahman): 8 passages in 133 ms. Passage [1] is the PM-004 public summary, public, updated 2023-10-19, ranked first by vector similarity and second by keywords, with a fused RRF score of 0.0325. Passage [2], from the same summary's Impact section, says "A number of invoices were charged twice during the slow period." On the left, the filters by kind (4 docs, 1 ADR, 3 issues, 0 PRs) and "Searching as" with Noor's two groups.

Search as Fatima: a green notice says nothing was filtered and 4 passages come from a restricted group she belongs to

The same query as Fatima. Now the notice is green: "Nothing filtered. 4 passages come from restricted groups you belong to (restricted:payments-incidents). Someone outside those groups would not see them." The kind counts change to 7 docs, 0 ADRs, 1 issue, and 8 passages come back in 20 ms. The top two passages are still the public summary; the restricted full postmortem's passages rank lower on this page.

Nothing in the server's code knows who Noor or Fatima is beyond their groups. The query is the same; only the list of groups in the WHERE clause differs. For Noor, the restricted chunks are not in the candidate set at all, so they cannot push a public passage down or leak through a score. The withheld count is computed separately, from ids and visibility labels only, never from titles or text (pre-filtering versus post-filtering is Module 10). The count is of sources, not passages; a wording fix made after these screenshots, so the notice now reads "2 restricted sources".

SAME QUESTION, TWO PEOPLE What happened in the duplicate-charge incident? NR Noor Rahman Frontend Engineer, joined 2026-10-06 public team:frontend FA Fatima Al-Sayed Payments Lead public team:payments restricted:payments-incidents SEARCH PLAYGROUND, SAME QUERY 2 restricted sources filtered out excluded in SQL before ranking Nothing filtered 4 passages from restricted:payments-incidents CLAUDE CODE'S ANSWER, LIVE TEST “A number of invoices were charged twice while PayRail was slow.” “312 invoices charged twice, across 58 shipper accounts” “The search hid 2 more matching passages” “USD 187,400 charged twice and refunded in full” CITES CITES pm-004-summary billing-api#39 pm-004 pm-004-summary billing-api#39 Same server, same index, same code: only the caller's groups in the WHERE clause differ.
Red is what the filter hid from Noor, green what Fatima's groups allow, amber the client model's words: the server handed each person different passages, and the model could only write from what it was given.

5. The same question in Claude Code

The playground shows passages. A real client turns them into an answer. On 25 October 2026 the author ran five questions through Claude Code 2.1.302 in headless mode (claude -p), with Wayfinder passed in a separate MCP config file, read-only tools allowed, and personal access tokens for Noor and Fatima that were revoked afterwards. Each question ended with "Use the wayfinder tools and cite the source ids you used." Below are short excerpts, word for word.

Noor asks about the duplicate-charge incident:

text
q3 · asked as noor.rahman
> What happened in the duplicate-charge incident?
turns 4 · 16624 ms · cost USD 0.175

The duplicate-charge incident is **PM-004**. It was a SEV1 in `billing-api` on 2023-10-02 [1].
[...]
## Impact
- A number of invoices were charged twice while PayRail was slow [1].
- Every affected invoice was found and fully refunded within 3 days, and account managers contacted the affected shippers [1].
[...]
## Gaps
- The full postmortem is restricted to the payments team because it contains customer and financial detail. Exact numbers and the timeline aren't in what I could read [1].
- The search hid 2 more matching passages because of permissions. For more detail, ask `#team-payments` or the owner, p-fatima-alsayed (Payments Lead) [1].

## Sources
- [1] `pm-004-summary`: https://wiki.tidewater.example/pm-004-summary
- [3] `billing-api#39`: https://github.com/tidewater/billing-api/issues/39

Fatima asks the same question and adds "Give the impact numbers":

text
q5 · asked as fatima.alsayed
> What happened in the duplicate-charge incident? Give the impact numbers.
turns 4 · 19975 ms · cost USD 0.186

[...]
**Impact numbers** [2]
- **312 invoices** charged twice, across **58 shipper accounts**
- **USD 187,400** charged twice and refunded in full. Refunds ran 2023-10-03 to 2023-10-05.
[...]
**Before you share this:** `pm-004` is restricted to `payments-incidents`. It says the invoice count, amounts, customer names and timeline must not appear in public docs, issues or PRs. If you're sharing more widely, cite `pm-004-summary` [1] instead, which leaves those details out.

Two things stand out. Noor's answer is useful and honest: it tells her what she may know, that more exists, and whom to ask, without one restricted number. And Fatima's answer warns her not to paste the restricted figures into a public place: pm-004 itself says those details must stay out of public docs, issues and PRs, and the server's instructions tell every client never to put restricted content into an issue. The wiki and GitHub addresses in these answers are the seed's display links for a company that does not exist.

The first question went just as well. Asked as Noor, "Why does billing-api retry a failed PayRail charge three times, and what happens after the third failure?" got an answer built on ADR-014, with the old policy marked as history:

text
- [2] `ADR-014`, billing-api PayRail retries, 3 attempts (accepted, current): https://wiki.tidewater.example/ADR-014
- [3] `ADR-010`, 5 attempts with a fixed 1 s delay (superseded by ADR-014; background only): https://wiki.tidewater.example/ADR-010

All five questions took 4 turns each, between 16.6 and 24.3 seconds, 97.6 seconds and USD 0.900 in total. Those are Claude Code's own model costs for that session, not a Wayfinder cost; Wayfinder's part of each answer is a few database queries (the live test is Module 16).

6. Superseded decisions arrive with their replacement

A search for multi-region filtered to ADRs, with a side panel open on ADR-022 that supersedes ADR-019

Noor searches "are we multi-region?" with only ADRs ticked and opens a result in the side panel: ADR-022, "Single production region with warm-standby DR", accepted, team platform, public, updated 2025-02-10, and "Supersedes ADR-019". Its context explains why the company went back to one region: infrastructure spend up 38%, the PM-007 split-brain incident, and Kafka replication work. Behind the panel, result [2] is ADR-019 with its title struck through, and further down a note reads "Deprecated, replaced by ADR-022".

Old decisions are the most dangerous documents in any wiki, because they look just as confident as the new ones. Wayfinder never drops them: they are history and they explain the present. But when an old ADR matches, its replacement is attached and listed right above it, so a model cannot cite the old rule as current (ranking and replacements are Module 9).

7. The tools a client can call

The Tools page shows the MCP surface exactly as a connected client sees it after the opening handshake: 8 tools, 4 resource templates and 3 prompts. Each tool has a description written for the model, an input schema, and a "Try it" panel that runs the tool as the signed-in person and is recorded in the audit log with the client name "console".

The get_issue tool with billing-api#87 as input; the response says the issue was closed as not planned

get_issue with {"repo": "billing-api", "number": 87}. The response, in 18 ms, begins: "billing-api#87: Raise PayRail retry attempts to 5 to cut failed payments (issue, not_planned ...)" and then "Closed as NOT PLANNED: this was rejected, not implemented." Below the arguments is the equivalent JSON-RPC request, tools/call, which is what a client actually sends. The left column lists every tool, resource and prompt; create_issue carries an amber "write" tag.

A closed issue is easy to misread as "done". This one was a request to raise the retry count, and it was rejected. Spelling out not_planned keeps a model from telling someone that billing now retries five times.

The who_owns tool for routing-service: the CODEOWNERS rule, team Routing, lead Diego Morales, on-call routing-oncall

who_owns with {"target": "routing-service"}, 21 ms: the CODEOWNERS rule ` @tidewater/routing, team Routing, lead Diego Morales, on-call rotation routing-oncall and Slack #team-routing, each cited to the repository's CODEOWNERS file and README. The structured part of the response starts below it with "found": true`: every tool returns both a text rendering for the model and structured data with a fixed schema (the tools are Module 11).*

8. The one write tool: create_issue

Seven tools only read. create_issue writes, so it has two locks: a scope and a human confirmation.

create_issue tried as Noor: a blue notice that her account has no issues:write scope and a red isError response

Noor tries to file "Alert when invoices stay in payment_pending_retry > 24 h" in billing-api. The blue notice warns before she clicks: her account has no issues:write scope, "so the server will deny this call. That denial is what a connected client would see." The response is marked isError and reads: "Permission denied: create_issue needs the issues:write scope, which Noor Rahman (engineer, frontend) does not have on this connection." The denial comes back as a tool result, not a crash, so a client's model can explain it to her.

create_issue tried as Fatima: the response says nothing was created and shows a preview

The same arguments as Fatima. This time the response is not an error, and nothing is created yet: "Nothing was created. Show this preview to the user; if they approve, call create_issue again with the same arguments and confirm: true." The preview repeats the repository, title, labels and body.

The bottom of the same page: structured content with status needs_confirmation and an amber bar with Confirm and Discard

Further down, the structured content says "status": "needs_confirmation", and the console shows an amber bar: "Preview only: nothing was created. A client with elicitation would ask you to confirm; here you confirm by calling again with confirm: true", with the title and two buttons, "Confirm and create issue" and "Discard".

A client that supports elicitation (the server asking the user a question in the middle of a tool call) shows Fatima a dialog with a "Create this issue" checkbox instead. A client without it gets the preview, and the model must show it and call again only after she agrees. Either way, nothing is written without a person saying yes (the write path and elicitation are Module 13). Issues filed this way are stored in Wayfinder's own database and indexed; nothing reaches a real GitHub.

9. The audit log, and who may read it

Every MCP call, from every client, leaves one audit row: who, through which client, which tool, the arguments, which sources came back, the status and the time taken. Since a fix made after these screenshots, the console's own searches and source reads leave rows too (console.search, console.get_source), failed and denied calls carry their error text, and a redacted row says how many results were withheld.

The audit log as Noor: 60 calls today, 1 user, 7 denied, and a table of her own console calls

Noor's audit log: "You see your own calls. Leads see their team's; security and leadership see everyone's." 60 calls today, 1 distinct user, 7 denied for a missing scope, 0 writes awaiting confirmation. Every row is hers, from the console, with its arguments ({"target":"routing-service"}), its result ("2 sources", or a red "denied") and its time in milliseconds.

The screenshots for this tour were taken in two rounds, about two minutes apart, so every tool call appears twice (16:10 and 16:12), and the overview in step 2, taken between them, counted 52 calls instead of 60.

The audit log as Fatima: 12 calls, 8 awaiting confirmation, two Claude Code calls, and three integration test rows

Fatima, as a lead, sees her team's calls: 12 today, 8 create_issue previews "awaiting confirmation", and 0 denied. Two rows came from Claude Code at 15:32: search_knowledge (5 sources, 28 ms) and get_document with {"id": "pm-004"}, the restricted postmortem read during the live test. At the bottom are traces of the integration pass: three rows whose body starts "Integration test from the console", one a preview later confirmed and one that created an issue (1 source, 31 ms), which is billing-api#167 from step 2.

The audit log as Priya: 74 calls, 3 users, and one Fatima row opened to show its arguments and a waiting for confirmation note

Priya, in leadership, sees every user's calls: 74 today from 3 distinct users. She has opened one of Fatima's create_issue rows: the full arguments on the left ("confirm": false), and on the right "Waiting for confirmation. Nothing has been created", result sources none, client console, 0 ms. The detail line gives the time in UTC (10:42:36), while the table shows local time (16:12:36).

Seeing a row is not the same as being allowed to read what it touched. Leadership sees every row but may not read restricted:security. When a row returned a source the viewer may not read, that source id is removed and the arguments are replaced by "arguments withheld: this call returned sources you may not read". This fix came out of the security review: before it, a VP could have read a security engineer's queries about a restricted incident in the log. None of the rows visible in Priya's screenshot touched a restricted security source, so none of them shows the withheld note (the audit log and redaction are Module 14).

10. The access matrix

The access matrix filtered to payments: eight people with ticks for public, team:payments and restricted:payments-incidents, 377 of 404 sources each

Fatima's access matrix, filtered to payments. Each row is a person, each column a visibility group, and the last column counts what they can read: 377 of 404 sources for every member of payments, from the lead to the newest engineer, because rights come from the team, not the title. The bottom row counts sources per group: 366 public, 0 for leadership, 10 platform, 8 payments, 6 routing, 4 frontend, 2 security, 3 payments-incidents and 5 security restricted. Below, three cards explain the rules, show the SQL that enforces them, and how they are tested.

The access matrix as Erik: his row has team:security and both restricted groups, 376 of 404; frontend engineers 370; leadership 399

Erik's view of all teams. His own row has team:security, restricted:payments-incidents and restricted:security: 376 of 404. The frontend engineers below, Noor among them, can read 370. Daniel Reyes, CTO, in leadership, has every team and the payments incidents but not restricted:security: 399 of 404. Only the security team can read those five sources.

11. Index and evaluation

Index and evaluation: sources and chunks per kind, freshness, the retrieval pipeline settings, and four metric cards

What is indexed: 404 sources and 1,155 chunks, from 154 issues (249 chunks) and 132 PRs (189) to 3 CODEOWNERS files (3). Freshness: last reindex at 14:48:45, the moment billing-api#167 was filed and indexed, with all 1,155 chunks embedded. The retrieval pipeline: BAAI/bge-small-en-v1.5, 384 dimensions on CPU, a pgvector HNSW index, Postgres full-text search in English, RRF with k = 60, the reranker off, chunks by heading of about 350 tokens with 50 overlap. Top right, "Reindex: platform and leadership only"; Noor cannot start one.

The evaluation cards and results by question kind: decision, denied, fact, recent, trap, who and why

The metric cards: hit@5 0.89 (threshold 0.85), MRR 0.72 (0.65), citation correctness 1.00 (1.00), and a green card for the CI gate, "0 leaks": 816 searches as 12 people, where any restricted source returned to someone outside its group fails the build. Below, the results by question kind for the 68 questions in seed/eval/questions.jsonl, recall@8 0.74, "all gates pass". The weakest kinds are "denied" and "who", both 0.78 at hit@5. These cards show the run from earlier that morning. The reports now on disk come from a later run on a freshly built index, with the numbers in the next section.

What is inside

WHAT IS IN THE BOX One MCP server, built and measured sources 403 docs, ADRs, issues and PRs chunks 1,154 each with a 384-dim vector MCP surface 8 + 4 + 3 tools, resources and prompts demo personas 6 of 41 people in 6 teams hit@5 0.909 68 questions, gate 0.85 MRR 0.734 first gold source, gate 0.65 permission gate 0 leaks 816 searches as 12 people automated tests 384 308 server, 76 console per live answer 19.5 s Claude Code, mean of 5
The project in nine numbers: blue is what it holds, green what the evaluation gates and the tests prove (no language model involved), amber the live test, where Claude Code's own model wrote the answers.
PartWhat it doesModule
The problemknowledge spread over wikis, ADRs, issues and PRs; assistants that see nothing or too much1
The solutionan MCP server with permission-aware retrieval and no language model of its own2
MCPthe protocol, its transports (Streamable HTTP, stdio) and the official Python SDK3
High-level designone server with four faces: /mcp, OAuth, /api and /healthz, behind nginx4
Low-level designtables, source ids, the console API and the tool schemas5
The seed companyTidewater Freight, its traps, the validator and the generated eval sets6
Access modelvisibility groups from the team, scopes, fail closed7
Indexingchunking, bge-small embeddings on CPU, incremental reindex8
Hybrid searchvector and full-text lists fused with RRF, freshness, superseded ADRs9
Permission filteringthe ACL inside every query, the withheld count, "not found" for unreadable ids10
Tools, resources, promptsthe eight tools, four resource templates and three prompts11
OAuth and tokensOAuth 2.1 with PKCE, dynamic registration, personal access tokens12
Writing safelycreate_issue, the scope check, elicitation and the preview fallback13
Audit logone row per call, who sees which rows, redaction14
The consolethe React pages in this tour and their contract tests15
Connecting clientsClaude Code, Cursor, Claude Desktop, MCP Inspector, the live test16
Evaluationhit@5, MRR, recall@8 and the 0-leak gate17
Securitythreats and the fixes from the security review18
Containersone command runs Postgres, the server and the console19
CloudGoogle Cloud with Terraform, keyless CI/CD20

Four things are not part of this project, and the modules say so where it matters. There are no connectors to a real wiki or GitHub: the company is loaded from the seed folder, and issues filed through Wayfinder stay in its own database. There is no single sign-on: people sign in with passwords, or with OAuth through Wayfinder's own consent page. There is no language model on the server, by design. And the Google Cloud deploy was written and documented but never validated or applied.

Results in numbers

Measured on 25 October 2026. The evaluation runs on the author's Mac with the real embedding model on CPU, an embedded Postgres, no reranker and no language model; it runs in CI on every push and fails the build when a gate fails. Module 17 explains every metric and gate.

What was measuredResultPass mark
hit@5: the first gold source in the top 5, over 66 scored questions0.9091 (6 misses)at least 0.85
MRR: mean of 1 / rank of the first gold source0.7337at least 0.65
recall@8: share of gold sources returned0.7601at least 0.60
Citations equal to the seeded source1.01.0
Superseded ADRs returned with their replacement1.0 (21 checked)1.0
Leaks: every question asked as 12 people, 816 searches, plus document fetches and recent changes0exactly 0
Restricted sources the filter kept out of someone's top 8 in that run976no gate
Search time in the retrieval suite16.6 ms median, 23.6 ms at the 95th percentileno gate

The 976 hidden sources are the control for the 0: the restricted material did match those questions, many times, and none of it reached anyone outside its group. The weakest question kind is "denied" at 0.778 hit@5, and the reranker was never switched on in an evaluation. An earlier run that morning scored hit@5 0.894 on an older index; the search code did not change, but the rebuilt index replaced stale chunks (Module 17 tells the story).

The live test with Claude Code, from step 5:

#Asked asQuestionTurnsTimeCost
q1NoorWhy does billing-api retry a failed PayRail charge three times?419.3 sUSD 0.209
q2NoorWho owns routing-service and who do I page if it breaks?417.5 sUSD 0.148
q3NoorWhat happened in the duplicate-charge incident?416.6 sUSD 0.175
q4NoorDid we ever use RabbitMQ? What do we use now and why?424.3 sUSD 0.182
q5FatimaWhat happened in the duplicate-charge incident? Give the impact numbers.420.0 sUSD 0.186

Five questions, 19.5 seconds on average, USD 0.900 in all, paid for Claude Code's model. Cursor and Claude Desktop were set up on the Connect page but not recorded in a run.

The tech stack

LayerChoice
ClientsClaude Code, Cursor, Claude Desktop (stdio bridge locally), MCP Inspector for debugging
ServerPython 3.12, the official MCP SDK (mcp 2.3.0, MCPServer), Starlette, uvicorn with one worker
AuthOAuth 2.1 authorization server (PKCE, dynamic client registration, refresh rotation), personal access tokens, argon2 passwords
RetrievalBAAI/bge-small-en-v1.5 through fastembed on CPU, pgvector HNSW, Postgres full-text search, reciprocal rank fusion
DataPostgreSQL 17 with pgvector, SQLAlchemy 2, Alembic
ConsoleReact 18, React Router, TypeScript, Vite, served by nginx
Quality and safety308 server and 76 console tests, a retrieval evaluation and a 0-leak permission gate in CI, a security review whose fixes carry tests, a seed validator
DeliveryDocker Compose locally; Cloud Run, Cloud SQL, Secret Manager and Terraform on Google Cloud (not applied); GitHub Actions with Workload Identity Federation

Run it yourself

You need Docker Desktop and nothing else: no API key, because the server has no language model, and the embedding model is downloaded into the server image while it builds. From the project folder:

bash
cd source-code
docker compose up -d --build

What you should see

Three containers start: postgres, server and console. The first start migrates the database, loads Tidewater Freight and builds the search index, which takes about a minute. Open http://localhost:8096 and sign in as noor.rahman@tidewater.example with the demo password Tidewater-Demo-2026 (published for local use; demo passwords are switched off in production). Search for "what happened in the duplicate-charge incident?", then sign out, sign in as fatima.alsayed@tidewater.example and search again.

To use it from Claude Code, add the server once and sign in:

bash
claude mcp add --transport http wayfinder http://localhost:8003/mcp

What you should see

Inside Claude Code, /mcp lists wayfinder; choose Authenticate, and your browser opens Wayfinder's consent page. After Allow, claude mcp list shows wayfinder: http://localhost:8003/mcp (HTTP) - ✓ Connected, and a question such as "who owns routing-service?" makes Claude Code call who_owns. Every call appears in the audit log with the client name Claude Code. Module 19 explains every container, and Module 20 puts the same server on Google Cloud.

Where to go next

Read the modules in order. Module 1 starts with the problem: why an AI assistant either sees nothing of a company's knowledge or sees far too much. Each module after it opens one box of the architecture diagram at the top of this page.