ekl MCP server
# Enterprise Knowledge Layer: RAG + MCP Reference Architecture
> All data is synthetic; Maple Harbour Bank is fictional.
AI assistants are only as trustworthy as the knowledge they are allowed to reach. In a regulated
bank, "connect the chatbot to SharePoint" is not an architecture: someone has to guarantee that the
model only sees **approved, current** content, that a teller's assistant cannot read an AML
investigation, that customer identifiers never leave the boundary, and that every retrieval can be
reconstructed for an auditor. This repository is a small, readable reference implementation of that
layer. It exposes internal knowledge to any [Model Context Protocol](https://modelcontextprotocol.io)
client (Claude Desktop, IDE agents, in-house copilots) through one governed server, with access
control, approved-only content, PII redaction and a tamper-evident audit log built in rather than
bolted on. About 700 lines of Python, one runtime dependency (the official MCP SDK), 62 tests.
## Architecture
```mermaid
flowchart LR
client["MCP client<br/>(Claude Desktop, agent, copilot)"]
subgraph server["ekl MCP server (python -m ekl.server)"]
tools["Tools<br/>search_knowledge · get_document · list_sources"]
policy["Policy / access layer<br/>caller role → max classification"]
retriever["Retriever interface<br/>(BM25 today, vector store tomorrow)"]
redact["PII redaction<br/>(every tool output)"]
end
corpus[("Approved corpus<br/>review_status = approved<br/>sha256 matches manifest")]
manifest[["manifest/manifest.json<br/>(python -m ekl.ingest)"]]
audit[("audit/audit.jsonl<br/>hash-chained, doc_ids only")]
client -- "stdio / HTTP + OAuth" --> tools
tools --> policy --> retriever --> corpus
manifest -. integrity check .-> corpus
retriever --> redact --> client
tools -- "who, what, which doc_ids" --> audit
```
Every request passes through the same five controls in `ekl/knowledge.py`, in order:
**integrity → access → retrieval → redaction → audit.** The MCP server and the CLI demo are thin
channels over that one service, so a new channel cannot accidentally skip a control.
## Governance controls
| Control | Where | Risk it addresses |
|---|---|---|
| Approved-only serving | `knowledge.select_servable` | Draft, pending or retired policy presented to staff as current guidance |
| Content integrity (SHA-256 vs. manifest) | `ingest.py`, `knowledge.select_servable` | Unreviewed edits or tampering with source documents after sign-off |
| Role-based access by classification | `policy.py` | Over-exposure of confidential / restricted content (e.g. AML cases) to roles without need-to-know |
| Filter *before* ranking | `Retriever.search(allowed_ids=...)` | Leakage through scores, snippets or "near-miss" results |
| Uniform "not found or not accessible" | `KnowledgeService.get_document` | Confirming the existence of restricted documents by probing ids |
| PII redaction on every output (and on logged queries) | `redact.py` | Personal information (SIN, account, card, email, phone) reaching the model, its logs or the user |
| Citations with doc_id, version, effective date | `knowledge.citation` | Unverifiable answers; inability to trace an answer back to its source |
| Hash-chained, append-only audit log | `audit.py` | Untraceable access; silent alteration or deletion of access records |
| Corpus manifest (versioned, per-doc hash) | `manifest/manifest.json` | No record of which content versions were in scope at a given time |
These controls are designed to **support** model-risk management practices such as those in OSFI
Guideline E-23 (Model Risk Management, effective May 1, 2027): inventory of grounding sources, data
lineage, monitoring and traceability. They do not by themselves make any system compliant with
E-23 or any other regulation.
## Quickstart
Requires Python 3.10+.
```bash
make install # pip install -e ".[dev]" (add --break-system-packages / use a venv as needed)
make ingest # validate front-matter, write manifest/manifest.json
make test # 62 tests
make demo ROLE=teller QUERY="suspicious transaction escalation"
make demo ROLE=compliance QUERY="suspicious transaction escalation"
make verify-audit # check the audit log hash chain
make serve ROLE=analyst # MCP server on stdio
```
The same query returns different results by role: a teller gets the branch cash-handling standard;
compliance additionally gets the restricted AML escalation procedure, with the sample SIN, account
number, phone and email redacted.
## Roles
Demo identity comes from `EKL_USER_ID` and `EKL_ROLE` (default role: `public`, least privilege).
| Role | Max classification | Sees |
|---|---|---|
| `public` | public | Complaint policy, service fees |
| `teller` | internal | + operations procedures, data classification, expenses |
| `analyst` | confidential | + mortgage pre-approval, model risk policy |
| `compliance` | restricted | + AML escalation procedure |
Other configuration: `EKL_CORPUS_DIR` (default `corpus/`), `EKL_MANIFEST` (default
`manifest/manifest.json`), `EKL_AUDIT_LOG` (default `audit/audit.jsonl`).
## Try the integrity check
```bash
# 1. Edit an approved document after it was registered
sed -i 's/\$85/$850/' corpus/mhb-fin-008-travel-expense-policy.md
make demo QUERY="meal per diem"
# (excluded MHB-FIN-008: content hash does not match manifest)
# 2. "Approve" the pending draft by editing its front-matter
sed -i 's/review_status: pending/review_status: approved/' corpus/mhb-ai-009-genai-acceptable-use.md
make demo QUERY="generative AI acceptable use"
# still excluded: the hash no longer matches what was reviewed
# 3. Tamper with the audit log
sed -i '1s/"teller"/"compliance"/' audit/audit.jsonl
make verify-audit
# TAMPERING DETECTED: line 1: entry hash mismatch (modified)
git checkout corpus/ && make ingest # restore
```
Re-running `make ingest` is the deliberate "publish" step: in a real pipeline it would run only after
the document owner's approval in the content workflow, and the manifest would be signed and stored
outside the reach of document authors.
## Connect Claude Desktop
Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows:
`%APPDATA%\Claude\`). Use absolute paths; Claude Desktop does not start in the repo directory.
```json
{
"mcpServers": {
"maple-harbour-knowledge": {
"command": "/Users/you/enterprise-knowledge-layer/.venv/bin/python",
"args": ["-m", "ekl.server"],
"env": {
"EKL_USER_ID": "u-1001",
"EKL_ROLE": "teller",
"EKL_CORPUS_DIR": "/Users/you/enterprise-knowledge-layer/corpus",
"EKL_MANIFEST": "/Users/you/enterprise-knowledge-layer/manifest/manifest.json",
"EKL_AUDIT_LOG": "/Users/you/enterprise-knowledge-layer/audit/audit.jsonl"
}
}
}
}
```
Then ask, for example, *"What is the process for a credit card dispute? Cite the source."*
## Identity in production
Reading the role from an environment variable is a **demo convenience only**: whoever starts the
process chooses the role. In production:
- Run the server over MCP's streamable HTTP transport behind the bank's identity provider, using the
MCP authorization spec (OAuth 2.1). The MCP SDK supports this via a `TokenVerifier`.
- Build the `Caller` per request from **verified token claims** (subject, group / entitlement
claims), never from anything the client or the model can set.
- Map entitlements to clearance in the policy layer, sourced from the bank's access-management
system, and extend `can_read` with attributes beyond classification (business line, region,
purpose) as needed.
- Ship audit entries to an immutable store (WORM storage or SIEM) in addition to, or instead of,
the local JSONL chain, and periodically anchor the latest hash externally.
Because the rest of the code only ever sees a `Caller`, this is a change to `server.py` alone.
## Swapping in a vector store
Retrieval sits behind a one-method interface in `ekl/retriever.py`:
```python
class Retriever(Protocol):
def search(self, query: str, top_k: int, allowed_ids: Iterable[str]) -> list[Hit]: ...
```
To use Amazon Bedrock Knowledge Bases, Azure AI Search, or Postgres with pgvector, implement
`search` against that service and pass it to `KnowledgeService(..., retriever=MyRetriever())`.
Translate `allowed_ids` (or, at scale, the caller's clearance and approval status) into the
service's **metadata filter** so access control is enforced inside the query, not by
post-filtering top-k results. Keep ingestion writing the same manifest so the integrity and
approval checks continue to apply regardless of where the vectors live.
## Project layout
```
ekl/
config.py environment-driven settings
corpus.py front-matter parsing and validation
ingest.py manifest builder (python -m ekl.ingest)
policy.py roles, clearance, Caller
retriever.py Retriever interface + pure-Python BM25
redact.py PII redaction
audit.py hash-chained audit log (python -m ekl.audit)
knowledge.py the governed service: integrity → access → retrieval → redaction → audit
server.py MCP server (python -m ekl.server)
demo.py CLI demo (python -m ekl.demo)
corpus/ 11 synthetic Maple Harbour Bank documents
manifest/ generated manifest.json
tests/ pytest suite
```
## Limitations
- **Demo identity.** Role comes from environment variables; see *Identity in production*.
- **BM25, not semantic search.** Lexical matching misses synonyms and paraphrase. That is
intentional here to keep the code dependency-free and readable; swap in a vector store or hybrid
retriever for real use.
- **Regex redaction.** Catches well-formed identifiers only. Production systems should add a
dedicated PII/NER service and treat regexes as a backstop.
- **Local audit chain.** Detects tampering but cannot prevent someone with file access from
truncating the tail or rewriting the whole chain; anchor hashes externally in production.
- **Not production-hardened.** No rate limiting, no HA, no secrets management, no load testing,
no document-level ACLs beyond classification, and whole-document (not chunk) indexing.
Works with the MCP Python SDK 1.x (`FastMCP`) and 2.x (renamed `MCPServer`).
## License
MIT. See [LICENSE](LICENSE).
Author: Sanjay Singh
All data is synthetic; Maple Harbour Bank is fictional.
TDQS
Scored across 3 tools
search_knowledge (query-based retrieval) and list_sources (permission-scoped enumeration) are both discovery-oriented and could momentarily be confused, but the descriptions clearly separate query search from browsing the allowed corpus. get_document is unambiguously a fetch-by-id operation with a distinct input signature.
All three tools follow the same verb_noun snake_case pattern (search_knowledge, get_document, list_sources) with consistent, predictable verb choices for query/fetch/enumerate semantics.
Three tools is at the low end but each maps to a distinct retrieval need (discover by query, enumerate permitted corpus, fetch full text), so there is no padding. Slightly thin for a document platform, but well-scoped for a read-only knowledge endpoint.
The discovery-to-retrieval lifecycle is covered: list what you can see, search it, then fetch full text by doc_id. Minor gaps exist, such as no way to retrieve multiple documents or inspect version history/related documents, but core workflows are not blocked.