About
A basic MCP server for Toridion DMM Associative Memory
README
MCP (Model Context Protocol) server that exposes the TQNN DMM associative memory API as callable tools for Claude and other MCP-compatible LLMs..
This makes DMM a live memory layer inside the LLM context window — Claude can call TQNN's associative retrieval during inference without DMM knowing anything about LLMs.
Licence: MIT
Architecture
Claude (reasoning/generation)
│ MCP tool calls [OAuth 2.1 Bearer token]
▼
tqnn-mcp-server ◄──── similarity orchestration, overlap scoring, PQR/FPD opt-in,
│ per-employee auth + DMM credential routing
│ multipart/form-data HTTP
▼
TQNN DMM appliance ◄──── pure associative memory primitive (searchDoc / storeDoc),
its own internal tokenisation and hashing,
dataset ACL enforcement (tqnn_acl_gate())
DMM is a pure associative-memory primitive: an input goes in, an O(1)-addressable key comes out, on both the store and search side. storeDoc.php splits every field name and value on whitespace/punctuation and hashes each resulting word with its own fixed-width token function, unconditionally — this happens regardless of what the client sends. searchDoc.php treats its incoming pattern as a single opaque string, strips it to alphanumeric+underscore, and hashes it once as a single atomic lookup key — it does not tokenise a multi-word query into separate searches.
Everything above that — deciding whether to pre-hash a value before sending it (pqr), whether to cross-check a reversed hash to filter false positives (fpd), how a free-text query gets split into individual search calls, how those results get combined and ranked, employee auth, and DMM credential routing — is client-side logic implemented in this server, not part of DMM itself. Dataset-level access control itself is enforced at the appliance, not here — see Authentication & per-employee access for exactly where the line sits.
Tools exposed
| Tool | Description |
|---|---|
tqnn_status |
Ping DMM — confirm connectivity at session start |
tqnn_search |
Single searchDoc call — exact associative match on one query term. pqr (default false) and fpd (default false) — see Query modes below |
tqnn_similarity |
Multi-call similarity orchestration — free text → ranked results, IDF-weighted token overlap scoring. pqr (default true) and fpd (default true, ignored when pqr:false) — see Query modes below |
tqnn_similarity_plain |
Same weighted-overlap algorithm as tqnn_similarity, always in plain (unhashed) mode — equivalent to calling tqnn_similarity with pqr:false, exposed as its own tool for callers who want a plaintext-only entry point with no flags to set |
tqnn_store |
storeDoc wrapper — write associations into DMM. pqr (default false) and fpd (default false) — see Query modes below |
tqnn_get |
Resolver — retrieve content for any filereference via ping / info / fetch. See docs/resolvers.md |
pqr and fpd default to false on tqnn_search and tqnn_store, matching the DMM REST API and Workbench convention where both are opt-in. tqnn_similarity defaults pqr to true instead — tqnn_similarity_plain is the explicit, flagless route to its pqr:false behaviour.
Query modes: PQR, FPD, and plain
Three independent tools — tqnn_search, tqnn_store, tqnn_similarity — share the same two boolean flags. What they mean, and what must match between store and search for a lookup to succeed:
| Flag | Effect | Default |
|---|---|---|
pqr |
PQR-hash the value client-side before sending it to DMM, using the same self-salting scheme DMM's own tqnnToken16() uses. |
false on tqnn_search/tqnn_store, true on tqnn_similarity |
fpd |
False Positive Defence — make a second call on the reversed input string's hash, and keep only results present in both the forward and reversed searches. Requires pqr:true on both the store and search side; ignored (and reported as false) whenever pqr:false, since there is no hash to reverse. |
false everywhere except tqnn_similarity (true) |
Plain mode (pqr:false) sends the value to DMM untouched. This still works for full free-text search, including multi-word values and purely numeric ones — DMM's own storeDoc.php tokenises and hashes every word server-side regardless of what the client sends (see Architecture), so a plain-stored record is just as independently searchable, word by word, as a PQR-stored one.
Matching matters, not hashing itself. A value stored with pqr:true produces a different (double-transformed) key than the same value stored with pqr:false, because DMM hashes on top of whatever the client sends either way. Searching with a pqr setting that doesn't match how the record was stored produces zero results, silently — there's no error, the search simply never reaches the right key. The rule of thumb: whatever pqr (and, if used, fpd) a record was stored with, search for it with the same setting.
tqnn_similarity_plain and tqnn_similarity called with pqr:false are the same code path — pick whichever is more convenient for the caller.
Modes
| Mode | Use case | Auth |
|---|---|---|
stdio |
Claude Code, local development | None — credentials from environment |
sse |
claude.ai remote connector, production | OAuth 2.1 required, optionally per-employee (see below) |
Quick start
1. Install
npm install
cp .env.example .env
Edit .env and fill in your credentials — see the Configuration section for all variables.
2. Test DMM connectivity
Before involving Claude at all, confirm the server can reach your DMM appliance:
node test.js
Expected: ping ✓, searchDoc ✓, similarity results ✓
Fix any failures here before moving on — check TQNN_BASE_URL, credentials, and network.
3. Choose your deployment
Option A — Claude Code (stdio, local)
No auth required. Add to ~/.claude/settings.json:
{
"mcpServers": {
"tqnn-dmm": {
"command": "node",
"args": ["/absolute/path/to/tqnn-mcp-server/index.js"],
"env": {
"TQNN_BASE_URL": "https://tqnn.local",
"TQNN_API_KEY": "your_key",
"TQNN_API_SECRET": "your_secret",
"TQNN_DATASET": "your_dataset",
"NODE_TLS_REJECT_UNAUTHORIZED": "0",
"MCP_MODE": "stdio"
}
}
}
}
That's it — Claude Code picks it up automatically. Per-employee accounts (below) don't apply in stdio mode — there's no OAuth handshake to attach a username to, so the server always uses the static .env credential pair here.
Option B — claude.ai remote connector (SSE + OAuth 2.1)
This is the production path. The server runs in SSE mode behind ngrok, with a self-contained OAuth 2.1 authorization server handling authentication.
How the OAuth flow works:
claude.ai tqnn-mcp-server (Pi5 / server)
───────── ───────────────────────────────
GET /sse ──────────────────────► 401 + WWW-Authenticate: Bearer resource_metadata=…
GET /.well-known/oauth-protected-resource ◄── { resource, authorization_servers }
GET /.well-known/oauth-authorization-server ◄── { authorization_endpoint, token_endpoint, … }
POST /oauth/register ──────────► { client_id, client_secret } [auto, no user action]
GET /oauth/authorize ──────────► HTML consent form
↕ you log in in your browser
POST /oauth/authorize ──────────► 302 → claude.ai/api/mcp/auth_callback?code=…
POST /oauth/token ──────────► { access_token, refresh_token, … }
GET /sse (Authorization: Bearer …) ──────────────────────────► SSE stream open
POST /messages (Authorization: Bearer …) ────────────────────► MCP tool responses
Token lifetime: 1 hour access / 30 days refresh (rotated on each use). All state is in-memory — a server reboot means users re-authenticate with one click in claude.ai.
The consent form checks credentials against tqnn_mcp_users.json if present (per-employee accounts), falling back to the single TQNN_OAUTH_USER/TQNN_OAUTH_PASS admin login if it isn't. See Authentication & per-employee access below.
Step 1 — Prepare your .env
Ensure MCP_MODE=sse is set, then add the four OAuth variables:
MCP_MODE=sse
# Public HTTPS URL of this server (your ngrok URL).
# Update this whenever ngrok restarts on a free plan.
TQNN_PUBLIC_URL=https://your-ngrok-url.ngrok-free.app
# HMAC secret for signing access tokens. Generate once, keep fixed.
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
TQNN_MCP_SECRET=your_generated_secret_min_32_chars
# Credentials for the OAuth consent page (fallback admin login — see
# Authentication & per-employee access below for the per-employee path).
TQNN_OAUTH_USER=admin
TQNN_OAUTH_PASS=your_strong_password
The server hard-exits on startup if TQNN_MCP_SECRET or TQNN_OAUTH_PASS are missing.
Step 2 — Start the server
Local machine (dev):
# Terminal 1
node index.js
# Terminal 2 — expose publicly
ngrok http 3100
Raspberry Pi 5 or remote server (production):
# Start ngrok in the background
nohup ngrok http 3100 > ~/ngrok.log 2>&1 &
curl -s http://localhost:4040/api/tunnels | grep -o '"public_url":"[^"]*"' | head -1
# → copy that URL into TQNN_PUBLIC_URL in .env
# Start with pm2
cd ~/dmm-mcp-server
pm2 start index.js --name tqnn-mcp
pm2 save
pm2 startup # follow the printed sudo command to survive reboots
Step 3 — Verify OAuth discovery
curl https://your-ngrok-url.ngrok-free.app/.well-known/oauth-authorization-server
You should see a JSON document containing authorization_endpoint, token_endpoint,
registration_endpoint, and "code_challenge_methods_supported": ["S256"].
Also check the health endpoint:
curl https://your-ngrok-url.ngrok-free.app/health
Step 4 — Connect claude.ai
- Go to claude.ai → Settings → Integrations → Add MCP Server
- Paste your SSE URL:
https://your-ngrok-url.ngrok-free.app/sse - Leave the OAuth Client ID / Client Secret fields blank — claude.ai registers itself automatically via Dynamic Client Registration.
- Save. A browser tab opens to your consent page.
- Log in with your employee account (or
TQNN_OAUTH_USER/TQNN_OAUTH_PASSif you haven't set up per-employee accounts yet) and click Approve. - You'll be redirected back to claude.ai. The connector shows Connected.
Step 5 — Test it
Start a new conversation in claude.ai, enable the TQNN connector, and try:
Use tqnn_status to confirm you can reach the DMM appliance, then search for
"diabetes mellitus" using tqnn_similarity and tell me what you find.
4. Verify the MCP protocol layer (optional)
This test spawns the server in stdio mode and exercises the full MCP JSON-RPC
handshake — initialize, tools/list, and tool calls for all three read tools.
Run it before connecting to Claude if you're setting up for the first time.
node mcp-test.js
All five tests should pass. test.js tests raw DMM connectivity; mcp-test.js
tests the MCP protocol layer on top.
There's also a standalone resolver-layer test that needs neither a DMM connection nor the MCP stack — useful when you've just edited tqnn_resolvers.json (e.g. adding a new scheme) and want to confirm it parses and routes correctly before wiring it up end to end:
node resolver-test.js
Covers config loading, normaliseRef timestamp-stripping, NO_RESOLVER on an unknown scheme, and all built-in handlers (memory, local_jsonl, url, webhook/cold_storage) plus NOT_FOUND edge cases, using synthetic data. It does not exercise local_blob/local_lba directly (those need a live storage_target.py), but it will confirm any new scheme entry — like sn655_pool_ — is at least syntactically wired up correctly.
Ngrok free-plan note
On a free ngrok plan the tunnel URL changes on every restart. When it changes:
- Update
TQNN_PUBLIC_URLin.env pm2 restart tqnn-mcp- Remove and re-add the connector in claude.ai
On a paid plan you can reserve a fixed domain:
ngrok http 3100 --domain=your-fixed-name.ngrok-free.app
Set that as TQNN_PUBLIC_URL permanently and you configure claude.ai once only.
Useful pm2 commands
pm2 status # show running processes
pm2 logs tqnn-mcp # tail live logs
pm2 logs tqnn-mcp --lines 50 # last 50 lines
pm2 restart tqnn-mcp # restart after .env or code changes
pm2 stop tqnn-mcp # stop the server
Pulling updates from GitHub:
cd ~/dmm-mcp-server
git pull
pm2 restart tqnn-mcp
Remember: tqnn_resolvers.json, tqnn_mcp_users.json, and tqnn_mcp_credentials.json are all cached in-process (by mtime for the latter two, at startup for the resolver config) — edits to any of them need a restart (or, for the two per-employee JSON files, take effect on the very next request with no restart at all — see below).
Authentication & per-employee access
Two independent, file-based layers sit on top of the OAuth 2.1 flow described above. Both are optional — if their config files are absent, the server behaves exactly as a fresh install always has (single shared admin login, static .env DMM credentials). Nothing breaks if you never touch this section.
Per-employee login (tqnn_mcp_users.json)
When this file is present, it replaces the single shared TQNN_OAUTH_USER/TQNN_OAUTH_PASS admin login with individual employee accounts:
{
"users": {
"example_user": {
"label": "Example User — replace or remove this entry",
"password_hash": "REPLACE_WITH_OUTPUT_OF_tqnn_mcp_hash_password.js",
"status": "active"
}
}
}
- Generate a
password_hashwith:
(scrypt, random salt each run — re-running for the same password produces a different-looking hash and that's expected; either is valid.)node tqnn_mcp_hash_password.js "the employee's password" - Live revocation.
statusis checked at the OAuth consent screen and on every subsequent MCP request — not just at login. Set an employee's status to"disabled"and their access is revoked on their very next request, including if they're holding an unexpired access token. No restart needed. - If
tqnn_mcp_users.jsonis missing entirely, the server falls back to the legacy single-admin login from.env— a non-breaking change for anyone who hasn't set up per-employee accounts.
Per-employee DMM credentials (tqnn_mcp_credentials.json)
Separately, this file maps each authenticated employee (by username, matching the entry above) to their own DMM sub-credential pair:
{
"default": {
"sub_apikey": "REPLACE_WITH_SUB_APIKEY_FROM_ACL_CONSOLE",
"sub_apisecret": "REPLACE_WITH_SUB_APISECRET_FROM_ACL_CONSOLE",
"dataset": "REPLACE_WITH_ONE_OF_THIS_CREDENTIALS_PERMITTED_DATASETS"
},
"users": {
"example_user": {
"sub_apikey": "REPLACE_WITH_SUB_APIKEY_FROM_ACL_CONSOLE",
"sub_apisecret": "REPLACE_WITH_SUB_APISECRET_FROM_ACL_CONSOLE",
"dataset": "REPLACE_WITH_ONE_OF_THIS_CREDENTIALS_PERMITTED_DATASETS"
}
}
}
Important — where enforcement actually happens: the sub-credential pairs referenced here are generated on the DMM appliance itself, via its ACL console (tqnn_acl_manager.php's "generate" action) — not invented by this server. This file's only job is picking which credential pair to send for a given authenticated employee; the actual dataset whitelist enforcement happens appliance-side, in esec.php's tqnn_acl_gate(). This server never duplicates or re-implements that access-control logic — it's purely a routing layer on top of it.
- The optional per-user
datasetfield sets that credential's default dataset for calls that don't explicitly specify one (tqnn_status/ping being the main case) — it must be one of the datasets that sub-credential is actually whitelisted for on the appliance, or the ping will 403. - Resolution order: authenticated username → matching entry in
users→ elsedefaultentry → else the static.envTQNN_API_KEY/TQNN_API_SECRETpair. So an employee with no specific entry, and adefaultblock with placeholder values, still functions — it just uses the server's own static credentials, same as before this feature existed. - Employees authenticate once per OAuth session; every tool call on that connection reuses the same resolved DMM client (cached by credential pair), so credential resolution isn't repeated per tool call.
- stdio mode (Claude Code) has no OAuth username to look up, so it always uses the static
.envpair regardless of what's in this file.
Configuration
| Variable | Default | Description |
|---|---|---|
TQNN_BASE_URL |
https://tqnn.local |
DMM appliance URL |
TQNN_API_KEY |
(required) | DMM API key — also the fallback used when per-employee credentials aren't configured |
TQNN_API_SECRET |
(required) | DMM API secret — see above |
TQNN_DATASET |
(empty) | Default dataset/namespace |
NODE_TLS_REJECT_UNAUTHORIZED |
(unset) | Set 0 for tqnn.local self-signed cert only |
MCP_MODE |
stdio |
stdio or sse |
MCP_PORT |
3100 |
HTTP port (SSE mode only) |
TQNN_PUBLIC_URL |
(required in SSE mode) | Public HTTPS URL of this server |
TQNN_MCP_SECRET |
(required in SSE mode) | HMAC secret for token signing (≥32 chars) |
TQNN_OAUTH_USER |
admin |
Username for the OAuth consent page — legacy/fallback login, superseded per-user by tqnn_mcp_users.json when present |
TQNN_OAUTH_PASS |
(required in SSE mode) | Password for the OAuth consent page — same fallback scope as above |
CORS_ORIGIN |
* |
Restrict to https://claude.ai in production |
Per-employee login and DMM credential routing (tqnn_mcp_users.json, tqnn_mcp_credentials.json) are file-based, not env-configured — see Authentication & per-employee access above.
Documentation
| Doc | Description |
|---|---|
| docs/resolvers.md | tqnn_get resolver system — logical namespaces, handler types, migration, custom handlers |
Implementation notes
- DMM's internal token function — both
storeDoc.phpandsearchDoc.phprun every value they handle through the same self-salting scheme, independently of anything this server does:h1 = SHA-256(input)→mixed = input + h1→padded = mixed.slice(0, 16)→token = SHA-256(padded).slice(0, 16). The salt is derived from the input itself, so it defeats rainbow-table attacks without needing external key material or extra storage, and every input is lifted into the full 2²⁵⁶ hash space regardless of its own entropy. This happens on the appliance whether or not this server'spqrflag is set — see Query modes for whatpqractually controls at the client layer. The old constant-padding scheme (padding short tokens with*) is superseded and kept insimilarity.jsonly for reference/rollback — it's vulnerable to rainbow tables on low-entropy fields. Any dataset still stored under the old scheme needs re-ingesting before it will match self-salted search hashes. - FPD — False Positive Defence: two
searchDoccalls per token (forward + reversed input string), AND the result sets. Only filereferences in both are genuine. Implemented identically intqnn_store,tqnn_search, andtqnn_similarity; requirespqr:true, since there's no hash to reverse in plain mode. - Filelist — DMM returns filelist as newline-delimited string, not JSON array.
- Timestamps — DMM appends a raw unix timestamp directly onto whatever filereference string it's given, with no separator of its own. This is why every filereference written to DMM should always end in
::— that trailing::is what lets the timestamp be stripped cleanly back off on read (split on the last::, keep everything before it). One consequence worth knowing: if the original filereference had only one::-terminated segment at the very end (e.g.pool::lba5::sectors1::, no fpd suffix), the returned filereference after strip has no trailing::at all (pool::lba5::sectors1). If it had a::-delimited suffix after that (e.g. anfpd_XXXXXXXX::specialcode), one::survives in the middle. Either way: never write a custom resolver handler that requires a trailing::to parse correctly — split the string on::and match tokens by position instead. See docs/resolvers.md for the full explanation and both handlers (local_blob,local_lba) that already do this correctly. - TLS —
NODE_TLS_REJECT_UNAUTHORIZED=0for tqnn.local only. Remove for any public HTTPS DMM endpoint with a valid cert. - Node 18+ — uses built-in
FormData,fetch, andcrypto. No extra HTTP packages required. - OAuth tokens — HMAC-SHA256 signed, 1-hour lifetime, constant-time validation. Refresh tokens rotate on use (30-day lifetime). All state in-memory. Token validation also carries a live per-employee revocation check (see Authentication & per-employee access) — this is in addition to, not instead of, signature/expiry checks.
Security
- OAuth 2.1 with PKCE (S256) is enforced on all SSE endpoints. Unauthenticated requests receive
401 + WWW-Authenticate. TQNN_OAUTH_PASSis SHA-256 hashed immediately on startup and never held in memory as plaintext. Per-employee passwords intqnn_mcp_users.jsonuse scrypt with a random salt per entry (salt_hex:hash_hex), verified in constant time.- Token comparisons use
crypto.timingSafeEqualthroughout. - Dynamic Client Registration is open (no pre-shared secret required) — consistent with the MCP spec and how claude.ai connects.
tqnn_storeis still protected behind OAuth. - Set
CORS_ORIGIN=https://claude.aiin.envfor production deployments. - Remove
NODE_TLS_REJECT_UNAUTHORIZED=0if your DMM appliance uses a properly signed certificate. tqnn_mcp_credentials.jsonandtqnn_mcp_users.jsoncontain (or reference) credentials/secrets — treat them like.env, not like general-purpose config: don't commit real values, and restrict file permissions on the server the same way you would for.env.- Dataset-level access control is enforced on the DMM appliance (
esec.php'stqnn_acl_gate()), not in this server — see Authentication & per-employee access for the exact division of responsibility.
Troubleshooting
"Couldn't reach the MCP server"
- Verify ngrok is running:
curl https://<ngrok-url>/health - Check
TQNN_PUBLIC_URLin.envmatches the ngrok URL exactly (no trailing slash). - Check both
.well-knownendpoints return JSON.
Claude asks for OAuth Client ID / Client Secret
- Leave both fields blank. Claude registers itself automatically.
Consent page shows "Invalid credentials"
- If using per-employee accounts: check the username exists in
tqnn_mcp_users.json,statusis"active", and the password matches whattqnn_mcp_hash_password.jswas run against. - Otherwise: check
TQNN_OAUTH_USERandTQNN_OAUTH_PASSin.env. pm2 restart tqnn-mcpafter any.envchange (edits totqnn_mcp_users.json/tqnn_mcp_credentials.jsondo not need a restart — they're re-read on the next request).
An employee's access won't revoke / they can still call tools after being disabled
- Confirm their entry in
tqnn_mcp_users.jsonhas"status": "disabled"exactly (not"inactive"or removed entirely — a missing entry falls through to "no per-employee accounts configured" logic differently than an explicitly disabled one in edge cases, so prefer explicit disabling over deletion). - This check is live on every request, so if access is still working, first confirm the file actually saved / the edit reached the server the process is running on.
"Session not found" after approving
- The server restarted between authorization and the first tool call. Reconnect the integration in claude.ai.
pm2 logs show "TQNN_MCP_SECRET must be set"
- The server exited on startup. Add
TQNN_MCP_SECRETto.env(≥32 chars) and restart.
Tokens stop working after a reboot
- Expected. In-memory state is cleared on restart. Re-authenticate in claude.ai (one click — claude.ai will prompt automatically).
About TQNN DMM
TQNN DMM is a deterministic, write-time-encoded associative memory architecture operating in high-dimensional semantic space. It achieves O(1) content-addressable retrieval without index structures. Architecturally aligned with Kanerva's Sparse Distributed Memory and VSA tradition.
It is not a vector database. It does not use embeddings or cosine similarity. Retrieval is associative: multiple associations may occupy shared high-dimensional space and resolve differently depending on retrieval cue — a superposition property consistent with Kanerva SDM and biological episodic memory.
More: toridion.com | Dataset: Toridion/lindisfarne-m1
Installing Dmm
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/forshaws/dmm-mcp-serverFAQ
Is Dmm MCP free?
Yes, Dmm MCP is free — one-click install via Unyly at no cost.
Does Dmm need an API key?
No, Dmm runs without API keys or environment variables.
Is Dmm hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Dmm in Claude Desktop, Claude Code or Cursor?
Open Dmm on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectAmap Maps Mcp Server
MCP server for using the AMap Maps API
by duxiaohuiSupabase
Database, auth and storage
by SupabaseEverything
Reference / test server with prompts, resources, and tools.
Git
Tools to read, search, and manipulate Git repositories.
Sequential Thinking
Dynamic and reflective problem-solving through thought sequences.
Time
Time and timezone conversion capabilities.
Compare Dmm with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
