DKE MCP — Wire & Tool Contract

Version: alpha · API 1 Status: Published contract for the DKE service wire. Audience: Tenants and third-party tooling that call the DKE service at dke.langsyn.net — the tool surface, request shapes, response envelope, error model, and billing. The language you send over this wire is DKE Python; the unit you send it in is a module. If you are connecting for the first time, start with the MCP Tutorial. This document as Markdown: dke-mcp-ref.md — the same text this page is rendered from, for readers and tools that would rather have the source than the page.


1. Model

DKE is a deterministic language runtime addressed over MCP. You write a program in DKE Python and submit it; DKE returns a response that is a deterministic, reproducible function of the program and the store it runs against — reproducible bit-for-bit, and metered per operation rather than per token.

The program world is two levels:

  • A module is the unit you submit: a .dpy source with a run-once body. It is a named, stored object.
  • A module defines zero or more scripts (its defs). Each script is addressed module.script — module-qualified, so two modules may each define restock without collision.

Two verbs produce a computed response — compile (store a module) and run (execute). Everything else is management and introspection: where modules, scripts, and data live, and how you inspect, protect, and remove them.

The service stores only the compiled form of what you submit, never its source text: there is no source readback, for your own modules or for shared ones. You keep your own source.


2. Tool surface

Thirteen tools, in five groups:

Group Tools
Compute compile, run
Introspection spec, info, list, info_module, list_modules
Diagnosis checkup
Lifecycle forget, forget_rule, forget_constraint, forget_module
Store stores

Script-level verbs (info, forget) address a script by its qualified module.script name; forget_rule addresses a standing rule the same way (module.rule) and forget_constraint a standing constraint (module.constraint); module-level verbs (info_module, forget_module) address a module by its bare name.

Your key addresses exactly one store, created for you from your account. Stores are provisioned, shared, and deleted from the account dashboard, not from this wire.


3. Connect

  • Endpoint: https://dke.langsyn.net — MCP over HTTPS, JSON-RPC 2.0, protocol version 2024-11-05.
  • Auth: Authorization: Bearer dke_c_<…> (a consumer key). Tenant keys (dke_t_…) are rejected on this endpoint — they administer the account, not the engine.
  • Server identity: serverInfo = { name: "dke", version: "1" }. The version is the public API version, deliberately decoupled from the build version.
  • Handshake: standard MCP initializetools/listtools/call. The clientInfo a client sends about itself is recorded against the key it connected with, and surfaces in the dashboard. It is self-declared: it labels a connection, never authenticates one, and never affects access.
  • Store selection: every store-touching tool accepts an optional store_id (string, default "default"), at params level or inside arguments.
  • One key, one client. A key is the identity your writes are recorded under — written_by (Reference §11.2) reports it on every claim — so two clients sharing a key produce a history that cannot be separated afterwards. Mint each client its own from the dashboard. An additional key is issued read-only by default; raise its level only when that client writes. A key’s store is fixed for its lifetime; its access level is not, and is changed from the dashboard.

4. The two compute verbs

compile and run are the two ways to submit a source, and they differ in exactly one respect: compile keeps the compilation; run does not. “Keep it” versus “try it.”

4.1 compile — validate and store a module

Turn a module’s source into a named, stored unit (“compile and keep”). This is the only path that creates durable state (stored modules, scripts, rules, kinds). A .dpy source is a module (Reference §9.1): compile stores the module text under name and runs its body — executing its data declarations, installing its rules, and making its defs callable.

  • Params:
    • name (required, string) — the module’s name in the store. It MUST be a single identifier of the same shape import <name> accepts in DKE Python: an XID_Start character followed by XID_Continue characters, with no dot and no hyphen. A module you cannot name in an import statement is a module you cannot use, so the name is held to the language’s own rule.
    • source (string, optional) — the module text; omit to reload an already-stored module (re-run its body).
    • version (string, optional) — your label for this revision of the module, e.g. "2.1.0". Recorded as metadata and reported by info_module; it changes nothing about how the module resolves or runs. Compared for equality and never ordered, so any different value is a change — including going back to a label you used before. Omitting it on a later compile leaves the version already recorded alone, so an unrelated edit cannot erase a label. A module you never versioned simply has none.
    • language (enum, default "eng") — see §8.
    • store_id (string, default "default").
  • Success: _meta.status = "success", the module’s identity and the module.script names it installed.
  • Invalid source: isError = true, _meta.status = "compile_error", _meta.compile_status ∈ { parse_error, type_error }, _meta.diagnostics = [{ class, line, col, message, fix? }]. Nothing is stored: a module that does not compile leaves the store as it was.

The service MUST:

  1. Lex, parse, and typecheck the source.
  2. If any check fails, refuse the operation; no storage occurs.
  3. Otherwise, store every declaration of the unit under its own declared name, atomically (all of the unit or none of it), each executable by name, and run the module body once.

A unit that is exactly one def is just a one-declaration module — compile submits it like any other; there is no separate single-script verb.

What the response says. It acknowledges the submission, states what it installed — the classes, scripts, rules and computed fields the unit declared — and then reports what the module body did: its print output and its store effects, in execution order, because running the body is part of submitting the source (step 3 above).

Each part appears only when it has something to say. A module that declares nothing installable — a data declaration alone — states no installs; a module with no body reports none; a module with neither is acknowledged on its own. The installs are what this submission installed, so reloading a stored module by name states none: its declarations are already stored, and only its body is run again.

Name resolution, re-submission reconciliation, and namespacing are specified in Reference §9.

4.2 run — execute

Execute instructions and return a transcript plus usage.

  • source (string, required) — a program: statements that read, write, and call compiled scripts. Compiled and executed once, not stored; anything it remembers persists, but nothing is left callable and nothing appears in list. A source with no statements has nothing to execute and is refused.
  • language (enum, default "eng"), store_id.

A program has no parameters — write the values you want into the instructions — and it cannot create definitions. A source declaring a def, a class, a @rule, or a computed field is refused, with a message directing you to compile it. A program cannot return either; a value it wants to report, it prints or binds.

To execute something you compiled, import its module and call it:

{"source": "import inventory\ninventory.restock(\"widget-1\")"}

A value script composes the same way, its result bound in the program (n = inventory.count_on_hand("widget-1")); the binding appears in the transcript as a typed entry carrying the value.

What a run reports. The run executes against the store’s current state. The transcript renders in the natural language the source was compiled in (§8), and covers everything the run did, in execution order, including the effects of any module the program imported. Its call frame is the response header, not a body line: a successful run is headed OK program: <verdict> and a failed one error: program failed — <reason>. The verdict tallies what the run did — the count of claims written, claims removed, and print lines. Both store counts are in claims, not statements, and a statement that changed no state reports no effect at all — it earns no place in a tally of what changed, and its target is not listed among the effects either. One forget removes every claim at the address it names, so a statement that removed three reports three and one that found nothing to remove reports nothing. A range forget is the exception to that first clause and not to the second: it names the older versions before an instant rather than everything at the address, so it can report fewer than the address holds — and it never removes the value in force, which is why the count it reports can be zero at a cell that plainly has a value. A remember of a value a source has already recorded at that cell is likewise no change: the store is idempotent, so the write succeeds, adds no claim, and is reported as adding none. (update replaces rather than matching, so it records a claim even when the value is the one already there.) The body then lists, in execution order, the run’s output (the values the program prints) and its store effects (the remember/update/forget writes, each naming its target cell). Statements that change no state stay silent — assignment bindings, control flow (if/match/for), transaction verbs (branch/commit/rollback), and read/query calls — so the body carries the run’s outputs and durable effects, not its execution mechanics.

This human-readable render is presentation, not a versioned contract: a conforming implementation MAY format the header, verdict, and effect lines differently. What is pinned is that the render is deterministic (same source + same store → byte-identical transcript) and that the machine-readable result carries the run’s terminal status and its print sequence.

A run is not transactional: a failed run does not undo writes already executed by earlier statements (its data writes persist either way); only a branch block groups writes transactionally.


5. Response envelope

Every tools/call returns a JSON-RPC success envelope { jsonrpc, id, result } where result is:

result = {
  content: [ { type: "text", text: <rendered result> },
             <mirrored structured block> ],
  isError: <bool>,                         // true if the call did not succeed
  _meta: {
    status:      "success" | "refuse" | "engine_error" | "compile_error",
    tenant_id, key_id,                     // from auth, never from params
    answered_at,                           // RFC-3339 UTC
    ops,                                   // usage (see §7)
    refuse_reason?,                        // see §6
    // compile:  compile_status, diagnostics[]
    // run:      invoke_status, transcript[]
  }
}
  • A value a program computes rides in transcript[], as the typed entry for the binding that holds it. A program does not return, so there is no separate returned-value field.

  • Each entry carries a kind, naming which statement of yours produced it. There are exactly two, and both are named for the DKE Python construct they record:

    kind The statement that produced it
    assign a bare assignment — cur = current(K.s.a). The entry carries the bound value, its type, and name — the identifier you bound it to.
    print a print(...) call. The entry carries the printed value, and no name, because a print binds nothing.

    name is how you tell two bindings apart. A program that binds several intermediates produces several entries of the same kind, and without the name the only thing separating them is line. Match on name when you want a particular value out of a transcript; it is the identifier from your own source, spelled exactly as you wrote it.

    Only value-bearing statements appear. Control flow — if, for, match, try, with branch — runs without adding entries, and so do write verbs like remember. A transcript is the values your program produced in order, not a trace of everything it did. If you need to see that a branch was taken, print something in it.

  • Each transcript[] entry carries a line, and line is a line of the source the entry came from — which is not always the source you submitted. A module’s lines run as part of your run in two ways: its body runs when you load it, and its scripts run when you call them. Either way its entries join your transcript and carry from_module, naming the module they came from; your own entries carry no such field. Read line against from_module when it is present, and against your own source when it is not. Where one module reaches another, the field names the module an entry is actually from, not the one that reached it — so a line number of yours and a line number of a module’s are never confusable, even when they are the same number.

  • A line that runs many times is reported, not repeated. A loop would otherwise emit one entry per iteration, making the response as long as the work rather than as long as the answer. So for each line, the first few entries and always the last are kept, and the repetitions between them are dropped. The last is always kept because it holds the value that line finally produced — the answer. print output is never dropped at all: it is your program’s own output. When anything was dropped, transcript_omitted gives the count, so a shortened transcript is never mistaken for a short one; a program that repeats nothing carries no such field.

  • _meta is the canonical structured channel, and carries the whole run.

  • Because some MCP clients do not surface _meta to the model, the same structured block is mirrored as a trailing content[] text item (leading \n, JSON-parseable). Clients SHOULD prefer _meta; the mirror is a compatibility affordance.

  • The mirror carries the same fields, but not the whole run — it is your results: every print, and the final value of each statement you wrote. A module’s own working, which carries from_module, is not among them; it is on _meta. Its own transcript_omitted counts what the mirror left out, which is more than _meta left out. Read the mirror to see what a program answered, and _meta to see how it got there.

The envelope reports outcome, usage, diagnostics, and a transcript of the program’s visible outputs and store effects — the observable result of running your program, nothing about how the service is built internally.


6. Error model

A failure reaches you at one of three levels, and a client has to handle all three. The first is the one people miss: not every response is a JSON-RPC envelope. A request that never gets as far as the tool surface is answered with an HTTP status and a small JSON body, so parsing every response as JSON-RPC breaks on the first mistyped key.

HTTP (no JSON-RPC envelope; body is {"code": "…"}):

Status code Meaning
401 AUTH_MISSING no Authorization header
401 AUTH_MALFORMED present but not a well-formed bearer key
401 AUTH_EMPTY_TOKEN Bearer with nothing after it
429 RATE_LIMITED too many requests — wait and retry
503 UPSTREAM_UNAVAILABLE the service is not reachable right now — retry

A 401 also carries WWW-Authenticate: Bearer realm="DKE". Everything past this level answers 200 with a JSON-RPC body, including every error below — so an HTTP status other than 200 means you never reached the tool surface, and is the cheapest check a client can make first.

JSON-RPC (HTTP 200, error member):

Code Meaning
-32700 parse error (malformed JSON body)
-32600 invalid request (not valid JSON-RPC 2.0)
-32601 method / tool not found — including a retired tool name (see §10)
-32602 invalid params (missing source; unsupported edition/language; an unqualified forget/info script name)
-32000 server error (an internal exception, returned as a generic "engine error")
-32001 payment required — the wallet cannot cover the call, the account is suspended, or the account does not include API access. Clears by paying: top up at langsyn.com/buy/
-32002 rate limited — clears by waiting. Carries a Retry-After header in seconds

-32001 and -32002 are deliberately different codes, because the two clear by opposite actions: one by paying, one by waiting. A client that cannot tell them apart will back off when it should top up, or top up when it should back off. Route on the code, never on the message.

Application (transport-success, isError = true):

  • _meta.status ∈ { refuse, engine_error, compile_error } — never "success". isError is exactly _meta.status != "success", so the two can never disagree.
  • _meta.refuse_reason ∈ { OutOfSurface, IncompleteSlots, Ambiguous, ScopeForbidden }.
  • compile diagnostics: class ∈ { parse_error, type_error } with line/col.

The three failing statuses differ in whose problem it is, which is what you route on: compile_error means your source did not compile — read diagnostics and fix it; refuse means the call was declined — read refuse_reason; engine_error means the service could not carry the call out — retrying is reasonable.

Naming something that does not exist — a script, a module — is an engine_error, and is the one case where retrying will not help: the call is well-formed and there is nothing at the address, so list or list_modules to find the right name. Every failure reaches you on the status, so routing on isError alone is sufficient to tell a completed call from a failed one; you never have to read the prose to find out which happened.

Refusals name a category, not an internal cause: a scope refusal does not say which permission was missing, and a capacity refusal is a generic “service at capacity.”

6.1 The repair loop — a rejected submission, its fix, and the resubmit

A compile_error is not a dead end, and this is the loop most agent workflows spend most of their turns in. Every diagnostic carries message for a person and, where the correction is mechanical, fix for a program. They are separate fields on purpose: one response serves a reader and a client without serving either badly, and a client applying fix never has to parse prose.

Submit a module written on Python reflexes:

{ "name": "watch",
  "source": "def watch(n: int):\n    i = 0\n    while i < n:\n        print(\"checking\")\n" }

The submission is refused whole — nothing is stored — and the diagnostic says both what is wrong and what to write instead:

{ "isError": true,
  "_meta": {
    "status": "compile_error",
    "compile_status": "parse_error",
    "diagnostics": [{
      "class": "parse_error",
      "line": 3, "col": 5,
      "message": "DKE Python has no `while` loop — iteration is bounded: `for <var> in <finite collection>:` over a store-derived collection (e.g. `list attributes of K`). Bounded iteration is the termination guarantee; there is no unbounded loop.",
      "fix": "for <var> in <collection>:"
    }]
  }}

line and col say where, fix says what to write, and the resubmission is the same call with that edit applied:

{ "name": "watch",
  "source": "def watch(k: string):\n    for a in list attributes of k:\n        print(a)\n" }

Two properties make this a loop a client can drive rather than a message a person must read:

  • A rejected compile stores nothing. The store is exactly as it was, so a retry is a retry and never a partial write to undo (§4.1).
  • fix is present only when the correction is mechanical. Its absence is not an omission — it means the diagnostic has no single edit to offer, and message is the whole of what the service can say. Treat fix as optional and fall back to message; never require it.

The diagnostics most worth expecting are the ones where a Python habit is the wrong reflex here — no while, no recursion, procedures do not return a value, and a list has no .append. Each of those refuses with the DKE Python form to write instead, so the first submission an agent writes from Python memory is usually one edit from compiling rather than a rewrite.


7. Usage & billing

  • _meta.ops — the number of operations the call consumed, an opaque non-negative integer. It counts what the program ASKED FOR, so the same question costs the same however you spell it.
  • An operation is counted each time it runs, not once for each time you write it. An operation inside a loop over a hundred subjects is a hundred operations, so a program’s cost follows the data it works over rather than its length.
  • 1 LCN buys 100,000 operations, debited from the account wallet after the response is served. It is a capability measure, not a cost breakdown.
  • A run over a suspended key is refused with -32001 before it executes — the service never runs on credit.

8. Language selection

Language is chosen out of band, in params — never in a source header:

  • edition (enum, default "python") — the concrete language. Today "python" (DKE Python) is the only accepted value.
  • language (enum, default "eng") — the natural-language keyword surface. Accepted values are "eng" and "nob".

Any other value is rejected with -32602 at the parameter gate, before the engine. More languages are planned; a new concrete language is a new edition value and a new keyword surface is a new language value, both over this same wire and the same response contract.

8.1 What language selects

language selects the keyword surface — the words the runtime reserves — and nothing else. Verbs, keywords and the words in the response are in the tag you ask for; the kinds, subjects, attributes and values you supply are your data and are stored and returned exactly as written.

The same program in both surfaces:

{"name": "run", "arguments": {
  "language": "eng",
  "source": "remember(Sensor.s1.room, \"kitchen\", \"survey\")\nc = current(Sensor.s1.room)\nprint(c.value)"}}
{"name": "run", "arguments": {
  "language": "nob",
  "source": "husk(Sensor.s1.room, \"kitchen\", \"survey\")\nc = gjeldende(Sensor.s1.room)\nskriv(c.value)"}}

Both write the same claim and both print kitchen. Sensor, s1, room, kitchen and survey are yours and do not change; remember/husk, current/gjeldende and print/skriv are ours and do. value does not: a field of a result is addressed by the same name in every surface.

This has three consequences worth stating plainly:

  • A store is not language-scoped. A module compiled under "nob" is read, invoked and forgotten by exactly the same names as one compiled under "eng", and the two see each other’s claims. There is no separate Norwegian store and no migration between surfaces.

  • Two words are English on every surface. The two diagnostic verbs, which exist only in English — a program in any surface writes them in English or the call is not recognised. Everything else in the keyword surface localises, the except handler kinds and the compound verb heads included. Reference §12 carries the same list, and the same check holds both.

    English on every surface. why_not what_needs

  • A module remembers the surface it was compiled in. info_module reports it as the module’s init language, and a script’s transcript renders in that surface — so a Norwegian module keeps answering in Norwegian even when a later call omits language and takes the "eng" default.

The verbs that operate on a stored script — list, info, forget — are edition-neutral and take no edition: only the source-carrying verbs, compile and run, select one.


9. Introspection & management

9.1 Language

  • spec(edition, topic?) — serve the DKE Python language contract, full or a topic slice ∈ { header, types, verbs, statements, scripts }.

9.2 Scripts — address by module.script

  • list(target = scripts | rules | kinds) — stored module.script signatures, standing-rule names, or the kinds your data is stored under (§9.2.1). A script’s line carries its full call signature: the name and its typed parameters in declaration order (inventory.ingest(id: int, payload: string, dry_run: bool)). Listing order is implementation-defined but MUST be stable across reads with no intervening compile / forget. In expression position inside a program, list_scripts() evaluates to a string_list of those names — the one lifecycle read that is also callable in-language, because it is a read-only data read.
  • info(name) — a script’s compile provenance: the compiling authority, the natural language the source was compiled in, and the manifest version the compiled form was produced against. Invocations are not tracked — info describes the compilation event, not any run. (For a data derivation, use the in-language why(K.s.a), which returns a proof_tree; info’s response is text, not a typed value.)
  • forget(name) — remove one script. Idempotent: if no script with that name exists, the call succeeds.

9.2.1 list(target = kinds) — what the store already holds

The kinds your data sits under: what a class declares, and what a fact’s path names first. One per line, ordered as list’s other targets are — stable across reads with no intervening write.

It answers a question the other reads cannot, and the difference is worth stating because it is easy to be misled by. checkup reports what a store is missing for the reasoning standing over it, so it names a kind only when no rule reads it — write a rule over a kind and it stops appearing. subjects(...) and current(...) report what is in force, so a recorded value whose validity window does not cover the moment asked about reads exactly like a cell nobody wrote. This target reports what is there.

Ask it before writing 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 finds nothing may be reading past it rather than past nothing — get(K.s.a) shows every claim recorded at a cell, in force or not.

The engine’s own storage kinds are not yours and never appear here.

9.3 Standing declarations — address by module.<name>

A rule and a constraint are both standing declarations a module installs, and both are addressed the same way. They are removed by different verbs because they remove different amounts: one withdraws what it derived, the other has derived nothing.

  • forget_rule(name) — remove one standing rule, leaving the rest of its module in place. The facts the rule concluded are removed with it; the facts it concluded from are untouched, so re-compileing the module brings the rule and its conclusions back. A conclusion that another standing rule still supports survives. Removing a rule that is already gone succeeds. Use list(target = rules) to see the names.
  • forget_constraint(name) — remove one standing constraint, leaving the rest of its module in place. Nothing is reclaimed: a constraint concludes nothing, so there is no derived fact to withdraw, and the data it watched is untouched — including the subjects that breached it. Stopping the watch is not fixing the breach. Re-compileing the module brings the constraint back. Removing one that is already gone succeeds. checkup() prints the names, in its violated findings.

9.4 Modules — address by bare module name

  • list_modules() — module handles (name, script count, created-at). Listing order is implementation-defined but MUST be stable across reads with no intervening compile / forget_module.
  • info_module(name) — a module’s record: its init language (the natural language its body was compiled in), the module.script names it defines, the classes it declares when it declares any, its version if it has one, updated-at, and created-at. created is when the module was first stored; updated is when its content last changed, and a compile that submits identical content leaves it alone. Worth reading when a module you import may have moved: a call is resolved when the calling module is compiled, so a caller keeps running the callee body present at that moment until you compile the caller again. An unknown module is a clean not-found.
  • forget_module(name, confirm=true) — remove a module as a unit. confirm=true is required; it cascade-removes the module’s scripts, kinds, and their data, and is not reversible. It refuses, naming the referrer, if another stored module still references the target — so no dangling references can exist. Forgetting a module that does not exist is a clean not-found.

name here is the bare module name, never a module.script address.

9.5 Store

  • stores() — your store’s id, size, and created-at. Your key addresses exactly one store. Everything that acts on the store ITSELF — creating it, deleting it, snapshotting it, restoring it from a snapshot — happens in the account dashboard, not on this wire. Your key governs what may be done to the DATA INSIDE your store; the store’s own lifecycle belongs to the account that owns it.

9.6 Checkup — does your store meet the reasoning over it?

  • checkup() — no arguments, changes nothing, needs only a read key.

Reach for it when a query returns nothing and you cannot tell whether the answer is genuinely no. A rule that never fired and a rule that fired and found nothing produce the same empty result, and when the reasoning came from a module you did not write, nothing in the result tells you which one happened.

The report is a counts line followed by findings, one per line:

Line Means
unmet <Kind>.<field> no-such-kind a standing rule reads this cell and nothing of that shape exists in your store
unmet <Kind>.<field> no-values the kind is there and that field is empty
unmet <Kind>.<field> not-in-force you did record a value at that field, and its validity window does not cover the time asked about — the fact is in your store and is not current, so what it needs is a window or an anchor, not another write
unread <Kind> this kind of yours holds data and no standing rule reads it
near A ~ B an unread kind and an unmet one differ by a letter or two
counts rules=… unfirable=… kinds=… unread=… how many rules stand, how many cannot fire as things are, how many kinds hold data, how many nothing reads

A near line is the one to read first. Writing your facts under a kind spelled one letter differently from the one the reasoning reads produces both an unread line and an unmet line, and the pair names the mis-spelling in a way neither line does alone.

Findings name cells, not rules: what an unmet cell needs is your data, and the address of the data is the actionable part. Rules are counted rather than named.

An empty store reports its counts and nothing else. Listing order is stable across reads with no intervening change.


10. What lives elsewhere

Store snapshots are a store-lifecycle control, not an engine operation, so they are not on this wire: they live in your account dashboard beside your store’s other lifecycle controls, and restoring a store from a snapshot is available there. A key addresses the data within one store; the store’s own lifecycle belongs to the account that owns it.

Any tool name this document does not list is not served, and returns -32601.