DKE MCP — Tutorial
Version: 1.0.5, changed in rev 171 · alpha · API 1.5 · rev 171
Status: Published tutorial for the DKE service wire.
Audience: Anyone connecting a client to the DKE service for the first time.
This document as Markdown: dke-mcp-tutorial.md — the same text this page is rendered from, for readers and tools that would rather have the source than the page.
This tutorial walks the connection itself: getting a key, speaking the handshake, submitting your first module, running your first program, and reading what comes back. The MCP Reference is the exhaustive companion — every tool, every parameter, every field. The language you send is DKE Python.
This tutorial assumes you have used an HTTP API before. It does not assume you have used MCP.
Chapter 1 — What you are talking to
DKE is a deterministic reasoning engine addressed over MCP. That sentence carries three claims worth unpacking before you send anything, because each one changes how you should use the service.
Deterministic. The same program against the same store returns the same answer, byte for byte. There is no sampling, no temperature, no model. If a call surprised you, the store changed or the program did — those are the only two possibilities, which makes debugging a matter of looking rather than guessing.
A reasoning engine. You do not send questions; you send programs. The unit of work is a DKE Python source, and what comes back is a transcript of what that source did.
Addressed over MCP. The transport is the Model Context Protocol: JSON-RPC 2.0 over HTTPS, protocol version 2024-11-05. If your client already speaks MCP, DKE is an ordinary MCP server and you can skip to Chapter 3. If it does not, MCP is three request shapes, and Chapter 2 shows all three.
One consequence of “engine, not model” is worth stating early: you are billed per operation, not per token. A program that reads ten thousand claims costs what those reads cost, whether you wrote it in five lines or fifty.
Chapter 2 — Connecting
The endpoint and the key
| Endpoint | https:// |
| Protocol | MCP over HTTPS, JSON-RPC 2.0, 2024-11-05 |
| Auth | Authorization: Bearer dke_s_… |
| Server identity | serverInfo = { name: "dke", version: "1.5" }, and the release — stage, API version, revision — on _meta |
You get a store key — the dke_s_ kind — from your account dashboard. It is the only kind this endpoint takes. Any other kind of key is refused here — in particular one beginning dke_c_: that is your customer key, which no endpoint takes, so keep it private. If a key is refused and you are confident it is valid, check the prefix first; it is the most common mistake.
If no key arrives at all — a header left out of the configuration, or one a client dropped — the service answers 401 with AUTH_MISSING, but your client may not show you that. An MCP client that meets a 401 can take it as a request to sign in with OAuth, look for OAuth metadata this service does not publish, and report the failure of that instead: Claude Code, for one, says Dynamic Client Registration rejected (HTTP 404). That message means the Authorization header never reached the service. Add it, and the same configuration connects.
Your key addresses exactly one store. You do not choose a store per call and you cannot reach another account’s data with it. Creating, deleting and snapshotting stores happens in the dashboard, not here — this wire governs the data inside your store, never the store itself.
One key, one client
Give every client its own key. A key is not just a password for your account — it is the name your work is recorded under. Every claim written through it is stamped with that key, and written_by reports it. Two clients sharing one key produce a history in which their work cannot be told apart, and nothing can separate it afterwards.
This is easy to get wrong because of where the key lives. An MCP client usually reads it from a config file belonging to your user account, so every program you run as that user inherits the same key. A second assistant started in a fresh window is a fresh conversation, not a fresh credential: it will pick up the key already there and connect as you, without being asked for one.
So when you add a client, issue it a key from the dashboard rather than pasting the one you already have. Issuing is the same two clicks either way, and a key you can name is a key you can revoke on its own when that client is finished.
A client with no browser — a CI job, an agent, a server — can have its key issued on its own machine instead. dkekey, a program for Linux, macOS and Windows, and dke., a module of the dkepy package, each issue a store key there and send the account service only its hash, never the key (MCP Reference §3.1).
Give it only the access it needs. A key is issued at one of five access levels — read; read-add, which records new facts and cannot change or delete anything already recorded; read-write; read-write-delete; or none, which keeps the key but lets it do nothing until it is given a level again. An additional key defaults to read — the right level for anything exploring your data rather than building it. Raise it when the client actually needs to write. A client that only reports what it sees, such as a sensor or an import job, needs no more than read-add.
The dashboard shows, per key, when it was last used and which clients have connected with it, so one key in two places is visible without watching anything run. That last part rests on clientInfo in the handshake below, which a client sends about itself: it is self-declared, so read it as a label, not as proof. It is enough to notice a key spread by accident — the case that actually happens — and it is not authentication.
The handshake
MCP opens with initialize, then you ask what tools exist, then you call one.
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "my-client", "version": "0.1"}}}The answer carries the server’s identity and every number you were built against:
result = {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "dke", version: "1.5" },
instructions: "DKE alpha, API 1.5, rev …. One revision for the whole published surface: …",
_meta: { stage: "alpha", api_version: "1.5", revision: … }
}serverInfo. is the API version, MAJOR.: MAJOR moves only on a change you would notice, and MINOR when something is added. _meta carries the release — stage, api_version and revision — the three facts on the first lines of every published document, the MCP Reference and the Python Specification included. The revision is one number for the whole surface: it moves whenever any published document changes, so compare it with the revision you implemented against, here, before anything else. When it moved, each document’s Revision history says whether that document changed and how. instructions says the same in words, for clients that show it to a model and never surface _meta.
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}tools/ is worth calling once and reading, rather than trusting a list you copied from somewhere. It is the authority on what this deployment serves, and a tool name it does not return is not served — calling one gets you -32601.
Everything after that is tools/:
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "stores", "arguments": {}}}stores is a good first call: it touches nothing, and a successful response proves your key, your transport and your store all work before you have written a line of DKE Python.
Chapter 3 — The two verbs that compute
Nineteen tools are served, but only two of them compute. The rest inspect and manage. The two are compile and run, and they differ in exactly one respect:
compilekeeps the compilation.rundoes not.
“Keep it” versus “try it.” Almost every question about which verb to use answers itself once you know that.
compile — store a module
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "compile",
"arguments": {"name": "inventory",
"source": "class Item:\n on_hand: int\n\ndef restock(id: string, n: int):\n remember(Item(id).on_hand, n, \"restock-job\")\n"}}}This stores a module called inventory and runs its body. That second half surprises people: compiling is not a silent validation step. A module body’s data declarations execute, its rules install, its classes install, and its defs become callable. Compiling is how a module puts itself into effect.
If the source does not compile, nothing is stored — you get isError: true, _meta., and _meta. with a line, a column and a message per problem. A module that fails to compile leaves the store exactly as it was; there is no partial install to clean up.
run — execute instructions
{"jsonrpc": "2.0", "id": 5, "method": "tools/call",
"params": {"name": "run",
"arguments": {"source": "import inventory\ninventory.restock(\"widget-1\", 40)"}}}A run compiles and executes once and stores nothing. Anything it remembers persists — the data is durable — but nothing is left callable and nothing shows up in list.
Two limits follow from “instructions, not definitions”, and both are refusals rather than silent surprises:
- A program cannot define. A
sourcecontaining adef, aclass, a@ruleor a computed field is refused, with a message pointing you atcompile. Definitions are durable things;runis the verb that keeps nothing. - A program has no parameters and cannot
return. Write the values you want into the instructions, and report results by printing or binding them.
Which one do I want?
Ask what you want to survive the call.
| You want | Verb |
|---|---|
| a procedure you will call repeatedly | compile |
| a class, a standing rule, a computed field | compile |
| facts loaded once, as part of a module | compile |
| a one-off query or a script call | run |
| to try something before committing to it | run |
Chapter 4 — Reading the response
Every tools/ returns a JSON-RPC success envelope. Whether the call succeeded is a separate question from whether the transport did, and that distinction is the thing to get right early.
result = {
content: [ { type: "text", text: … }, <mirrored structured block> ],
isError: <bool>,
_meta: { status, tenant_id, key_id, answered_at, ops, … }
}Route on status, never on prose
_meta. is one of success, refuse, engine_error, compile_error, and isError is exactly status != "success" — the two can never disagree. So isError alone is enough to tell a completed call from a failed one, and you never have to read the human-readable text to find out which happened.
When it did fail, the status tells you whose problem it is, which is what you actually route on:
| Status | Whose problem | What to do |
|---|---|---|
compile_error |
your source | read diagnostics, fix the source |
refuse |
the call was declined | read refuse_reason |
engine_error |
the service, or something your program assumed | read the message: it says what happened |
An engine_error promises nothing about the next call, so read the message. Three cases are worth memorising. Naming something that does not exist — a script, a module — is an engine_error that calling again will not change. The call is well-formed and there is simply nothing at that address. Call list or list_modules and find the right name. So is a program that uses a value nothing holds. Reading .value off an empty read stops the run, and the message names the line and the guard it needs, a match with case empty:. The fix is in your program, not in the service. A store busy with another operation is an engine_error too. The statement that met it kept nothing, and the message says the store was busy and that nothing in the call needs changing.
Two channels, saying different things
The structured block appears twice, and this is deliberate rather than redundant.
_metais canonical, and carries the whole run — every step, including the working of any module your program imported.- The mirror (a trailing
content[]text item, JSON-parseable) carries the same fields but not the whole run: it is your results — what you printed and the final value of each statement you wrote. A module’s internal working is not in it.
It exists because some MCP clients never surface _meta to the model. Prefer _meta; treat the mirror as a compatibility affordance. The useful way to hold the difference: read the mirror to see what a program answered, and _meta to see how it got there.
Whose line number is that?
Each transcript entry carries a line — and it is a line of the source the entry came from, which is not always the source you submitted. When a module’s lines run as part of your run, its entries join your transcript carrying from_module. Your own entries have no such field.
So: read line against from_module when it is present, and against your own source when it is not. Your line 12 and a module’s line 12 are never confusable.
A call adds nothing under your line on the script’s behalf. Its parameters take your arguments without an entry, whether you call it as a statement or bind what it returns, so an entry with no from_module is always a statement you wrote.
Why a loop’s transcript is shorter than its loop
A line that runs many times is reported, not repeated — otherwise a response would be as long as the work rather than as long as the answer. For each line, the first few entries and always the last are kept; the repetitions between are dropped. The last is kept because it holds the value that line finally produced.
Your own print output is never dropped. And when anything was dropped, transcript_omitted gives the count — so a shortened transcript can never be mistaken for a short one.
Why a long value is shortened
A value that is very long would make the response as long as the data rather than as long as the answer: a program that saved a 16-qubit state would echo two megabytes of hex to say it had saved one thing. So a value in the transcript whose text is longer than 65,536 characters is shortened, and the entry says so. A list of 30,000 zeros reads 30000 entries [0, 0, 0, 0, 0, 0, 0, 0, +29992 more], and a blob reads <blob 1048576 bytes, shortened, begins 000000000000F03F>.
Nothing you compute is shortened. print writes the whole value, and so do str() and len(): when you want a long value back, print it.
Chapter 5 — When things go wrong
Check the HTTP status first. A request that never gets as far as the tool surface is answered with a status other than 200 and a small body {"code": "…"} — not a JSON-RPC envelope. Parsing every response as JSON-RPC is the first thing that breaks, usually on a mistyped key:
| Status | code |
Meaning |
|---|---|---|
401 |
AUTH_MISSING / AUTH_MALFORMED / AUTH_EMPTY_TOKEN |
no key, or not a well-formed one |
401 |
AUTH_CUSTOMER_KEY_NOT_ALLOWED |
your customer key (dke_c_) — keep it private, and send a store key (dke_s_) |
429 |
RATE_LIMITED |
too many requests — wait and retry |
503 |
UPSTREAM_UNAVAILABLE |
not reachable right now — retry |
Everything below answers 200. So a non-200 means you never reached the tools, and that is the cheapest thing to test for.
Transport-level failures that did reach them arrive as JSON-RPC error codes:
| Code | Meaning |
|---|---|
-32700 |
malformed JSON |
-32600 |
not valid JSON-RPC 2.0 |
-32601 |
tool not found — any name tools/ does not return |
-32602 |
invalid params — missing source, unsupported language, an unqualified script name |
-32000 |
server error |
-32001 |
payment required — clears by paying |
-32002 |
rate limited — clears by waiting; a Retry-After header says how long |
Three of these are worth a word.
-32602 on an unqualified script name catches people early. Scripts are addressed module., always — inventory., never restock. The qualification is what lets two modules each define a restock without collision, so the wire insists on it rather than guessing.
-32001 covers every reason the call cannot be paid for: the balance will not cover it, the account is suspended, or the account does not include API access. A run is refused before it executes — the service never runs on credit.
-32002 is a separate code from -32001 on purpose, and the distinction is the whole point of having two: one clears by paying, the other by waiting. Retrying a -32001 will never work, and topping up after a -32002 buys you nothing. Route on the code, never on the message.
One thing you will not get is a diagnosis of a refusal’s internal cause. Refusals name a category, not a reason: a scope refusal does not tell you which permission was missing, and a capacity refusal is a generic “service at capacity.”
The failure that is not an error
The hardest case is the one that never errors. A query that returns nothing is either a correct negative — the reasoning ran and the answer really is no — or a rule that never fired, because the facts it reads are not there. Both come back as an empty result, and when the reasoning came from a module you did not write, nothing in the response distinguishes them.
checkup() is the tool for exactly that moment. It takes no arguments and changes nothing:
{"name": "checkup", "arguments": {}}OK checkup:
counts rules=4 unfirable=1 unreadable=0 declined=0 kinds=3 unread=1 constraints=0 unevaluable=0 violated=0
unmet audit.Dependency.version no-such-kind
unread Dependancy
near Dependancy ~ audit.DependencyRead the near line first. Here the store holds facts under Dependancy and the reasoning reads audit. — one letter apart, which is why the query came back empty and why nothing complained. unmet names a cell some rule needs a value at that holds nothing; unread names a kind of yours that no rule reads. A mis-spelling produces one of each, and the pair names the mistake.
Findings name cells rather than rules, because a cell is the part you can act on: what it needs is your data.
Chapter 6 — What a call costs
_meta. is the number of operations the call consumed. Three rules govern it, and together they make cost predictable:
- It counts what the program asked for. The same question costs the same however you spell it, so you can write the clearer form without paying for it.
- An operation is counted each time it runs, not once for each time you wrote it. An operation inside a loop over a hundred subjects is a hundred operations — cost follows the data, not the source length.
- 1 LCN (LangCoin) buys 100,000 operations, debited from the account’s balance after the response is served.
Chapter 7 — The other tools
Everything that is not compile or run inspects or manages. Address scripts and rules by their qualified module. / module. name, and modules by their bare name.
| Tool | What it answers |
|---|---|
spec(edition, topic?) |
the DKE Python language contract, whole or by topic |
list(target) |
stored script signatures, standing-rule names, the kinds your data is stored under, or the marks standing in your store |
info(name) |
a script’s compile provenance |
list_modules() |
module handles — name, script count, created-at |
info_module(name) |
a module’s record — init language, scripts, classes, version, updated-at |
forget(name) |
remove one script (idempotent) |
forget_rule(name) |
remove one standing rule, leaving its module in place |
forget_constraint(name) |
remove one standing constraint, leaving its module in place |
forget_module(name, confirm) |
remove a module as a unit — recoverable only while a checkpoint stands |
checkup() |
whether your store meets the reasoning standing over it |
checkpoint(name) |
mark where the store is now, under a name you choose |
diff(since) |
what changed since the mark of that name |
restore(since) |
bring back the values removed since that mark |
forget_checkpoint(name) |
drop a mark, letting go of the removals it alone was keeping |
stores() |
your store’s id, size, created-at |
stop() |
stop the call running on your store, and say whether it stopped |
report(kind, text, …) |
tell us that something went wrong, or what you would like |
list(target = kinds) answers a question the others cannot, and it is worth knowing which question that is. checkup reports what your store is missing for the reasoning standing over it, so it names a kind only while no rule reads it — write a rule over a kind and it stops being mentioned. subjects(...) and current(...) report what is in force, so a value whose validity window has closed reads exactly like a cell nobody ever wrote. This target reports what is there.
Reach for it before you write facts into a store you did not start from empty. A kind you did not expect means data you did not put there — and a read that comes back empty may be reading past it rather than past nothing. get(K. then shows every claim recorded at that cell, in force or not.
Six behaviours here are easy to trip over:
forget_modulerequiresconfirm=trueand cascade-removes the module’s scripts, rules, kinds and their data. With a checkpoint standing,restorebrings it back as a unit — rules included, reasoning again — all of it or none of it; with no mark standing, it is gone. It also refuses, naming the referrer, if another stored module still references it — so you cannot leave a dangling reference behind.forgetis idempotent, butforget_moduleon a missing module is a clean not-found. Neither is an error you need to guard against.infodescribes the compilation, not any run. Invocations are not tracked. If you want to know how a value came to be, that is the in-languagewhy(K., which returns a proof tree — a different question with a different answer shape.s. a) restoreonly reaches removals that happened while a mark was standing. Taking acheckpointis what makes the removals after it recoverable, so it is worth doing before anything uncertain rather than after: nothing warns you at the moment of a removal that no mark was held. It brings back values and whole modules; a rule removed on its own withforget_rulecomes back by compiling it again. A mark keeps what is removed after it until you drop it withforget_checkpoint, and that keeping takes storage, so drop a mark once you no longer need to restore from it — and not before.stopwaits, andstoppingis not a failure. It stops whatever call is running on your store — a longrunanother connection is still waiting on, say — at that call’s next statement, then waits for it to let the store go. A statement already running finishes first, so if one is still running when the wait ends, the answer isstopping: callstopagain in a moment, andidlethen means it has stopped. Arunit stops keeps what it wrote before then, as any failed run does.reportsends only what you write in it, and we can read it. Your store is sealed to your key and we cannot look inside it, so a report is the one way to show us a program that misbehaves: put it insource, since nothing is taken from your store for you. A refusal that names its reason is not a bug — itsfixis the next step (Chapter 5). The answer is an id,rpt_…, and no reply is sent; an account may send 20 a day.
Chapter 8 — Where to go next
- The MCP Reference is this tutorial’s exhaustive companion: every parameter, every field of the envelope, the full error model.
- The Python Tutorial teaches the language you send over this wire.
- The Python Reference is the entry-per-construct lookup, including modules (§9).
- The Library Tutorial covers the modules we publish, which you
compileinto your store instead of writing the same declarations yourself.
The shape to carry away: two verbs compute and the rest manage; compile keeps and run does not; route on _meta., never on prose; and read _meta for how, the mirror for what.
Revision history
Newest first. The revision is the surface’s — one number stated by every published document, the web pages and the service wire, moving whenever any document changes. This list records the revisions at which this document changed and what changed in it; a revision absent here left this document as it was. The Version line at the top states this document’s version, the revision it last changed in and the current revision; from rev 148 each entry opens with the version that revision gave this document. The history starts at rev 1. What a revision promises is stated in the Python Specification, its section 11.1.
- 1.0.5 · rev 171 (2026-11-15) — The server’s identity shows
version: "1.5"andapi_version: "1.5"oninitialize, where it showed"1.3", which it had not answered since rev 163. The Version line states API 1.5: a module may state its version on a top-level__version__line, which acompilegiven noversionrecords andinfo_modulereports (Python Reference §9.1), and an addition moves the API’s MINOR. - 1.0.4 · rev 163 (2026-11-14) — The Version line states API 1.4:
rangeis a value of its own, walked without being built, so aforover a million numbers runs, and a range keeps its three bounds as.start,.stopand.step(Specification §8.6), and an addition moves the API’s MINOR. - 1.0.3 · rev 151 (2026-11-13) —
serverInfo.andversion _meta.areapi_version "1.3": a rule premise may test part of a text, asr.,path. startswith("/ srv/") t.andname. endswith(". test") "admin" in a.(Specification §14.2), and an addition moves the API’s MINOR.role - 1.0.2 · rev 150 (2026-11-13) —
serverInfo.andversion _meta.areapi_version "1.2": calls made with keys that may only read run on one store together, where every call on a store ran alone (MCP §9.8), and an addition moves the API’s MINOR. The Version line states API 1.2. Nothing else changed. - 1.0.1 · rev 149 (2026-11-13) —
serverInfo.andversion _meta.areapi_version "1.1": the API gained a form a rule’s conclusion may take, a text written from values the rule read (Python Specification §14.3), and an addition moves MINOR. The Version line states API 1.1. Nothing else changed. - 1.0.0 · rev 148 (2026-11-12) — This document states a version of its own, 1.0.0, first on the Version line and followed by the revision it last changed in, and from this revision each entry here opens with the version it made (Python Specification §11.1). Nothing else changed.
- rev 147 (2026-11-12) —
serverInfo.andversion _meta.areapi_version "1.0", the API version asMAJOR.: MAJOR moves only on a change you would notice, MINOR when something is added. The Version line states API 1.0.MINOR - rev 115 (2026-11-07) — Chapter 4: an
engine_erroris the service’s or something the program assumed, and a program that uses a value nothing holds is the third case worth memorising. - rev 113 (2026-11-06) — Chapter 4: why a long value in the transcript is shortened, and that
printwrites it whole. - rev 80 (2026-11-03) — Chapters 3 and 7:
report, the nineteenth tool, and what it sends. - rev 33 (2026-10-23) — The
-32601row covers any tool nametools/does not return.list - rev 31 (2026-10-19) — Chapter 8 names the Library Tutorial at its new address.
- rev 26 (2026-10-17) — The header states Version, Status, Audience and the Markdown copy, one field to a line, and the introduction follows it. Chapter 2: the account dashboard is linked, and a client with no browser can be issued its key on its own machine, by
dkekeyor bydke.inkeys dkepy(MCP Reference §3.1). Chapter 8 names the Modules Tutorial. The rev 5 entry below sits under this section’s introduction, with the others. - rev 5 (2026-10-11) — Chapter 2: a key is issued at one of five access levels; read-add records new facts and cannot change or delete anything already recorded.
- rev 4 (2026-10-11) — Chapter 1: DKE is a deterministic reasoning engine, the words the landing uses. Chapter 2: the
initializeanswer carries the release as_meta.,stage _meta.andapi_version _meta.— one revision for the whole published surface — andrevision instructionsstates the same; the two per-document revisions it carried at rev 2 are gone. A key is issued, not minted, at one of four access levels, none included; the kinds of key this endpoint refuses are no longer named by prefix beyond the customer key. LCN is spelled out as LangCoin at its first use. Cross-references take the published documents’ short names, the ones the footer uses — MCP Reference, Python Tutorial, Python Reference, Python Specification. - rev 3 (2026-10-11) — The revision is the surface’s: one number shared by every published document, the web pages and the service wire. This section says so, and the Version line moves from rev 2 to it. Nothing else changed.
- rev 2 (2026-10-11) — Chapter 2: the
initializeanswer is shown, and how to compare its_meta.andcontract_revision _meta.with the revisions you implemented against.spec_revision - rev 1 (2026-10-11) — The Version line states the document’s revision and this section is added. Nothing else changed.