# DKE MCP — Tutorial **Version:** alpha · API 1. 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 [Wire & Tool Contract](/ref/dke/mcp/) is the exhaustive companion — every tool, every parameter, every field. The language you send is [DKE Python](/tutorial/dke/python/). **This document as Markdown:** [dke-mcp-tutorial.md](/tutorial/dke/mcp/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 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 language runtime 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 language runtime.** 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 "runtime, 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_c_…` | | **Server identity** | `serverInfo = { name: "dke", version: "1" }` | You get a **consumer key** — the `dke_c_` kind — from your account dashboard. There is a second kind, `dke_t_`, and sending it here will not work: tenant keys administer the account, not the engine, and this endpoint rejects them. If a key is refused and you are confident it is valid, check the prefix first; it is the most common mistake. 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, mint it a key from the dashboard rather than pasting the one you already have. Minting 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. **Give it only the access it needs.** A key is issued at an access level, and an additional key defaults to **read-only** — the right level for anything exploring your data rather than building it. Raise it when the client actually needs to write. 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. ```json {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "my-client", "version": "0.1"}}} ``` ```json {"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`: ```json {"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 Eleven 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 ```json {"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 `def`s 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 ```json {"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 `remember`s 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: … }, ], isError: , _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 | retrying is reasonable | With one exception worth memorising: **naming something that does not exist — a script, a module — is an `engine_error`, and it is the one case where retrying will not help.** The call is well-formed and there is simply nothing at that address. Call `list` or `list_modules` and find the right name. ### 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. ### 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. --- ## 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 | | `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 — including a retired name | | `-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 wallet 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: ```json {"name": "checkup", "arguments": {}} ``` ``` OK checkup: counts rules=4 unfirable=1 kinds=3 unread=1 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 reads 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 buys 100,000 operations**, debited from the account wallet 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, or the kinds your data is stored under | | `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 — **not reversible** | | `checkup()` | whether your store meets the reasoning standing over it | | `stores()` | your store's id, size, created-at | **`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. Three behaviours here are easy to trip over: - **`forget_module` requires `confirm=true`** and cascade-removes the module's scripts, kinds *and their data*. It is not reversible. 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. --- ## Chapter 8 — Where to go next - The [Wire & Tool Contract](/ref/dke/mcp/) is this tutorial's exhaustive companion: every parameter, every field of the envelope, the full error model. - The [DKE Python Tutorial](/tutorial/dke/python/) teaches the language you send over this wire. - The [DKE Python Reference](/ref/dke/python/) is the entry-per-construct lookup, including modules (§9). - [Reasoning Modules](/tutorial/dke/reasoning-modules/) are 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.