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://dke.langsyn.net
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.keys, 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.version is the API version, MAJOR.MINOR: 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/list 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/call:

{"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:

compile keeps the compilation. run does 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.status = "compile_error", and _meta.diagnostics 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 source containing a def, a class, a @rule or a computed field is refused, with a message pointing you at compile. Definitions are durable things; run is 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/call 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.status 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.

  • _meta is 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/list 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.script, always — inventory.restock, 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.Dependency

Read the near line first. Here the store holds facts under Dependancy and the reasoning reads audit.Dependency — 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.ops is the number of operations the call consumed. Three rules govern it, and together they make cost predictable:

  1. 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.
  2. 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.
  3. 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.script / module.rule 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.s.a) then shows every claim recorded at that cell, in force or not.

Six behaviours here are easy to trip over:

  • forget_module requires confirm=true and cascade-removes the module’s scripts, rules, kinds and their data. With a checkpoint standing, restore brings 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.
  • forget is idempotent, but forget_module on a missing module is a clean not-found. Neither is an error you need to guard against.
  • info describes the compilation, not any run. Invocations are not tracked. If you want to know how a value came to be, that is the in-language why(K.s.a), which returns a proof tree — a different question with a different answer shape.
  • restore only reaches removals that happened while a mark was standing. Taking a checkpoint is 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 with forget_rule comes back by compiling it again. A mark keeps what is removed after it until you drop it with forget_checkpoint, and that keeping takes storage, so drop a mark once you no longer need to restore from it — and not before.
  • stop waits, and stopping is not a failure. It stops whatever call is running on your store — a long run another 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 is stopping: call stop again in a moment, and idle then means it has stopped. A run it stops keeps what it wrote before then, as any failed run does.
  • report sends 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 in source, since nothing is taken from your store for you. A refusal that names its reason is not a bug — its fix is 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 compile into 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.status, 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" and api_version: "1.5" on initialize, 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 a compile given no version records and info_module reports (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: range is a value of its own, walked without being built, so a for over a million numbers runs, and a range keeps its three bounds as .start, .stop and .step (Specification §8.6), and an addition moves the API’s MINOR.
  • 1.0.3 · rev 151 (2026-11-13) — serverInfo.version and _meta.api_version are "1.3": a rule premise may test part of a text, as r.path.startswith("/srv/"), t.name.endswith(".test") and "admin" in a.role (Specification §14.2), and an addition moves the API’s MINOR.
  • 1.0.2 · rev 150 (2026-11-13) — serverInfo.version and _meta.api_version are "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.version and _meta.api_version are "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.version and _meta.api_version are "1.0", the API version as MAJOR.MINOR: MAJOR moves only on a change you would notice, MINOR when something is added. The Version line states API 1.0.
  • rev 115 (2026-11-07) — Chapter 4: an engine_error is 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 print writes 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 -32601 row covers any tool name tools/list does not return.
  • 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 dkekey or by dke.keys in 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 initialize answer carries the release as _meta.stage, _meta.api_version and _meta.revision — one revision for the whole published surface — and instructions states 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 initialize answer is shown, and how to compare its _meta.contract_revision and _meta.spec_revision with the revisions you implemented against.
  • rev 1 (2026-10-11) — The Version line states the document’s revision and this section is added. Nothing else changed.