Ajisai

io.github.masamoto1982v0.7.0Updated Oct 1, 2026

Exact rational and algebraic computation, with machine-readable Word contracts, in Ajisai.

VerifiedSTDIODesktop onlyDeveloper ToolsData & Analytics

Overview

AI-generated overview

Lets an assistant run exact rational and algebraic computations in the Ajisai language, with contract checking and outcome prediction.

What it does
Exposes five tools over a deterministic Ajisai computation kernel: compute executes source under time, source, output and step limits; check parses, resolves names and verifies declared contracts without executing; infer_contracts infers contracts for user-defined Words; outcomes predicts the finite set of outcome ids a program could produce; word_contract queries the canonical contract registry. Results share one envelope and distinguish a value, a reason-carrying NIL, a language error and a host failure. Resources serve the quickstart guide, vocabulary, contracts, result schema and limits.
When to use it
Use it when an assistant needs exact arithmetic or symbolic algebra rather than floating-point approximations, or when a program's form, contracts and possible outcomes should be inspected before running it. It suits bounded, reproducible numeric work where results must be machine-readable and diagnosable.
Requirements
Node 20 or newer; the npm package ajisai-mcp-server carries its own WASM backend, so no build step or native binary is needed. It runs locally over stdio and is configured through a JSON server entry in the MCP client. No environment variables or credentials are required; AJISAI_BIN and AJISAI_REPO are optional overrides for a native binary or a local checkout.
Before you install
Execution tools accept source text only and deliberately take no file paths, so a tool call cannot read arbitrary local files. All five tools declare read-only, non-destructive and idempotent annotations. A host failure reports a stable error code and a retryable flag, and error messages omit host paths and environment-variable names. Ceilings such as wall time, response size and materialized elements can refuse or project a call to NIL rather than fail it.

Installation

In SourceWeft

  1. Open Ajisai in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

Ajisai MCP server

Ajisai's MCP surface makes the language useful as a bounded, deterministic and diagnostic computation kernel for AI agents. The server remains a thin adapter: the Rust CLI owns language semantics and generated artifacts own vocabulary.

Ajisai promises exactness in its supported numeric domain, rather than unqualified “no rounding errors”. Operations such as explicit rounding and functions outside that domain retain their documented semantics.

This README is the server's own record of what it does and how it is verified; the readiness tracker and the hand-off memo that once accompanied it in docs/dev/ were retired when their exit criteria were met. Host-by-host resource ceilings are compared in docs/dev/mcp-host-profiles.md.

Install and connect

Requirements: Node 20 or newer. Nothing else — no build step, no cargo, no native binary: the package carries its WASM backend.

The server is published on npm as ajisai-mcp-server and listed in the official MCP Registry as io.github.masamoto1982/ajisai. Most MCP clients take a JSON server entry. Claude Desktop (claude_desktop_config.json), Claude Code (.mcp.json) and Cursor (.cursor/mcp.json) all use this shape:

json
{  "mcpServers": {    "ajisai": {      "command": "npx",      "args": ["-y", "ajisai-mcp-server"]    }  }}

npx -y ajisai-mcp-server --doctor exits 0 when the installed copy can actually compute.

From a checkout

A clone runs the same server without npm, which is how the repository's own tests and an unreleased engine are reached. The WASM backend is committed under wasm/generated/, so a fresh clone computes immediately.

sh
git clone https://github.com/masamoto1982/Ajisai.gitcd Ajisai/tools/mcp-servernpm installnode index.js --doctor     # exits 0 when this copy can actually compute

The server entry then names the checkout's index.js:

json
{  "mcpServers": {    "ajisai": {      "command": "node",      "args": ["/path/to/Ajisai/tools/mcp-server/index.js"]    }  }}

That is the whole configuration. No env block is needed, and pointing AJISAI_BIN at a native binary — as an earlier version of this file did in its only working example — is an optional override, not a prerequisite:

  • AJISAI_BIN selects a native ajisai binary instead of the packaged WASM backend, which is how a Docker image that builds one in should be wired.
  • AJISAI_REPO is a development-only fallback for discovering a locally built binary without naming it.

Without either, a native binary is discovered only when this package is run from an Ajisai checkout (tools/mcp-server beside rust/Cargo.toml), and then the more recently built of rust/target/release and rust/target/debug is used. An installed copy under node_modules never looks outside itself: it runs the packaged WASM backend.

The published package holds only what the server runs — the adapter, its two backends, the packaged assets and WASM module, README.md, CHANGELOG.md and LICENSE. The evaluation harness, its corpora and the tests stay in the repository (npm run test:pack fails if one ships).

Both backends answer identically (see Backends and provenance); the override is about deployment, not about results.

Checking an installation

The server is silent when it is healthy, which makes it indistinguishable from one that is wedged. The same executable answers for itself:

sh
node index.js --version    # adapter version, engine version, registry digestnode index.js --doctor     # Node, assets, backend and two real computationsnode index.js --help

--doctor exits 0 when every check passes and 1 when any fails, so it can gate a container start or a support request. It computes 2 3 DIV 1 3 DIV ADD and 2 SQRT through the selected backend: a server that starts and loads its assets but answers wrongly is still broken, and only running something proves otherwise. With no arguments the process speaks MCP on stdin/stdout and writes nothing else there.

Agent surface

toolpurpose
computeexecute source with time, source, output and step limits
checkparse, resolve and conservatively verify declared contracts without execution
infer_contractsinfer contracts for user-defined Words without execution
outcomespredict, without execution, the finite set of outcome ids a program could produce
word_contractquery the complete canonical spec/words.json contract registry

Execution tools accept source text only. Deliberately omitting file-path input prevents an AI tool call from becoming an arbitrary local-file reader.

check answers about the form of a program, never about running it: it tokenizes, parses, resolves names and verifies declared contracts, all without execution. So a check that returns status: ok says the program is well-formed and its names resolve — it does not say the program will succeed, stay inside a ceiling, or produce a value rather than a NIL. Nothing that only a run can decide is decided here. Read it as "this will get as far as executing", and read compute for what executing it does.

The four source tools answer one program consistently, and tool-consistency.test.js (part of npm run selftest) holds them to it over every golden and corpus source:

  • compute's outcome is always in the set outcomes predicts.
  • Source that does not parse is malformedSource to all four — infer_contracts included — and outcomes answers it exactly.
  • check ok means the run does not fail on the program's form or its names.
  • check is stricter than a run: it rejects a name nothing defines even inside a Word that is never called, which a run never reaches. outcomes still allows for unknownWord there, and infer_contracts reports that Word as partial with a gap.unresolvedWord gap.

Three outcomes, kept distinct

Ajisai outcomestatusoutcome (compute)isError
a valueokvalue—
NIL(reason)ok, with the absence reasonnil:<reason>—
language ERRORerror, with the full diagnosiserror:<category>—
a failure of the hosthostError—yes

outcome uses the ids of spec/outcomes.json — the same ids the outcomes tool predicts — so a run is checked against its prediction with one membership test, and a reasoned absence is told apart from a value without reading the top stack node. outcomes answers exact: true only when it returns a single id: an empty program (value) or source that does not tokenize or balance its brackets (error:malformedSource). Every other program, a bare literal included, gets a sound superset with exact: false.

All five tools answer with the same envelope (result.schema.json, also served as ajisai://schema/result), so one schema describes every result a caller can receive and there is no second contract to keep in step.

What a result costs

Every result arrives twice: as structuredContent, which a caller branches on, and serialized into a text content block, because MCP asks a tool with an output schema to also return the serialized JSON that way — a text-only client has no other route to any of it. That mirror stays. Replacing it with a prose summary is the one compaction that would actually lose information, and the self-test pins that a text-only client can still tell a value, a reason-carrying NIL, a language error and a host failure apart from the text alone.

What was removed instead is padding. The text used to be written with two-space indentation, which cost about a third of it and told a machine nothing, and an optional field carrying no value used to be sent as null — so a plain success advertised message, diagnosis, aiDiagnostic and contractDecls, all empty. Those fields are now absent. Test for presence, not for null. Nested fields are untouched: one stated rule at the top level is worth more than the ~4% recursing would add.

Measured across the seven benchmark cases, the text block's median fell 32% (1,618 → 1,094 bytes) and the whole response's 22% (3,049 → 2,376). npm run eval:performance reports both and fails against a committed medianResponseBytesBudget, so the padding cannot come back unnoticed. The budget is a ceiling to lower when a response genuinely shrinks, never one to raise so a regression passes.

A host failure is machine-readable: error.code is a stable identifier (invalidRequest, unknownTool, sourceTooLarge, backendUnavailable, capacityExhausted, timeout, responseTooLarge, malformedBackendResponse, backendFailure, registryUnavailable), error.retryable says whether trying again can help, and error.limit names the declared ceiling when the failure is about one. error.message is written for a model and carries no host paths, environment-variable names or spawn diagnostics; that detail goes to the server's stderr, where an operator is looking.

Saturating concurrentExecutions queues the caller for up to a second before answering capacityExhausted, so an ordinary burst becomes back-pressure rather than a retry loop the caller has to write.

Reading an algebraic value

2 SQRT answers with the value, a rendering of it, and an approximation. On the stack node, read:

fieldwhat it is
stackDisplaythe value written as one token: "sqrt(2)", "2/1*sqrt(2)", "sqrt(2)-sqrt(3)", exact and never truncated
semantics.exactTermsthe value itself: Σ (numerator/denominator)·√radicand, arbitrary-precision integers as strings

The display renders exactly the terms beside it. Compute with exactTerms; the display is meant to be read rather than parsed.

The one that misleads is the node's own value: a rational approximation flagged semantics.approximate, so it looks exact and is not.

The display writes the canonical normal form, so equal values are written the same way: 8 SQRT and 2 SQRT 2 SQRT ADD both give 2/1*sqrt(2), and EQ decides they are the same number. Comparison decides equality here; string comparison does not — the string is display text, not a value. exactTerms does not appear on a rational or a vector of rationals, whose stackDisplay is already the whole value.

Diagnostics

An unknown Word answers with diagnosis.candidates — the closest known names, best match first, drawn from the compiled-in vocabulary and the Words the same source defines (each call runs in a fresh session, so the same source is the whole User dictionary). word_contract answers an unmatched name the same way, in suggestions.

Each nextChecks entry is { code, title: { en, ja }, detail: { en, ja } }. Match on code; the display text is localized and free to be reworded.

diagnosis is the one copy of an error's diagnosis. aiDiagnostic only classifies it — category (the spec/outcomes.json error category, the same id as in outcome), repair: "program" when the registry says the program is what to change (absent: an operand is wrong), word and family — and the error's errorFlowTrace event does not repeat it. A nilProduced event keeps its own diagnosis, since a NIL has no other; it is recorded once, at the Word that projected the NIL, and the Words it then passed through record nothing. Repeating the diagnosis three times is what made 1 ADD a 12 KB response; it is now 7 KB.

responseBytes bounds the response as sent: the structured result, its serialized text mirror and provenance together. A result whose single copy fits the ceiling but whose response does not is responseTooLarge.

A resource-limit failure carries diagnosis.resourceLimit ({ resource, limit, observed }), where resource is the name of the very entry in mcp.limits that fired.

A ceiling can refuse a call without failing it. A well-formed generative Word whose result will not fit — 0 100001 RANGE against materializedElements — projects to NIL under the NIL Projection Rule, so the call is status: ok and there is no top-level diagnosis to carry anything. The same facts are on the value that came back instead: stack[i].semantics.absence.diagnosis.resourceLimit, and on the nilProduced entry of errorFlowTrace. Read them there when absence.reason is spaceExhausted; reason says a ceiling fired, and only these say which one, what it is set to, and what size crossed it.

Backends and provenance

All execution tools call the same host-neutral Rust agent boundary (rust/src/agent) through one of two interchangeable backends (tools/mcp-server/backend/): a native ajisai subprocess per call, or the same agent code compiled to WASM and run inside a worker_threads Worker per call. Both return the identical result envelope — verified case by case in backend/parity-test.js — so Node never reinterprets command-specific results.

The backend is chosen once, at startup, and named in mcp.backend.kind (nativeCli or wasmWorker). Choosing per request meant a cargo build finishing mid-session silently moved later calls onto a different execution path, with nothing in the response saying so. Parity is what makes the two answers equal; provenance is what would make an unequal one investigable.

Every result also carries mcp.serverVersion, mcp.engineVersion, mcp.assetDigest and the applied mcp.limits. The two versions are two separately released components: serverVersion is this Node adapter, and engineVersion is the Ajisai language it speaks for. A saved result used to name only the second, so a field missing from an archived envelope could not be told apart from a field that adapter version never sent.

The packaged registry is verified against its recorded digest at startup, so a corrupt asset stops the server rather than surfacing as a generic failure on whichever request touched it first.

Limits

mcp.limits is also served as the ajisai://limits resource. Every entry has a matching entry in golden/limits.json, and the self-test fails if the two sets differ — a ceiling cannot be declared without saying how it is exercised. Six are pinned by real boundary sources run against the live server on every self-test and compared across both backends; concurrentExecutions is pinned through the adapter's own admission path; and numericWork, bigintBits and algebraicTerms are pinned in Rust with injected ceilings because they are not reachable within wallTimeMs at their declared values. golden/limits.json and docs/dev/mcp-host-profiles.md say so explicitly rather than leaving the gap to be discovered.

The playground applies a different, looser profile — 0 100001 RANGE succeeds there and answers NIL(spaceExhausted) here. Both hosts now publish what they apply, and the divergence is recorded as an explicit hostDivergence block on the golden case that shows it.

Resources

ajisai://guide/quickstart, ajisai://vocabulary, ajisai://contracts, ajisai://schema/result and ajisai://limits. ajisai://contracts is every Word's full contract in one read; ajisai://vocabulary is the inventory only — each name with its kind, family and vocabularyTier. The ajisai://words/{name} template exposes the same complete Word contract as word_contract without a tool call. Word names are case-insensitive, in lookups as in programs (add runs as ADD); the registry digest is calculated from the canonical specification, not from a reduced documentation manifest.

ajisai://guide/quickstart is an MCP preface (mcp-quickstart.md) followed by the generated writing protocol (SKILL.md), joined by sync-assets.js. The guide used to be SKILL.md alone, which opens on a CLI run loop — ajisai run file --json, commands a connected client cannot issue — and never says which of the tools to call, so a model that read it first learned the language before it learned the interface. The preface answers tool selection, result branching and the algebraic-value trap in one screen, then hands off. Its own examples are executed against the live backend by the self-test, the same guarantee the generator gives the half below it.

All five tools declare read-only, non-destructive and idempotent MCP annotations.

Development

sh
cd tools/mcp-servernpm installnpm run selftest       # uses the packaged WASM backend unless AJISAI_BIN is setnpm run test:packnpm run evalnpm run eval:validatenpm run eval:performancenpm run eval:number-baselinenpm run eval:tracesnpm run eval:repairs

The packaged WASM bundle (wasm/generated/) is regenerated by npm run build:mcp-wasm at the repo root. Vocabulary, contracts, guide, version and registry provenance are packaged under assets/ regardless of backend.

npm run test:mcp-backends at the repo root (builds the native binary, then runs backend/parity-test.js) runs every golden case and every declared limit boundary against both backends and asserts they agree.

eval/cases.json is the agent-evaluation corpus: 78 cases (58 positive, 20 negative), each asked in English and Japanese, so 156 prompts. npm run eval (the same run as npm run eval:traces) scores the corpus answering itself — score-traces.js --reference — which executes every case's expected tool call against the real backend. It measures backend semantic correctness only; model tool selection and source generation require captured model traces and are not claimed by this score.

Every case is bilingual because Ajisai is a Japanese-authored language with an English tool surface, so "does a Japanese prompt reach the same tool with the same source as its English twin" is a product question rather than a translation detail. Both halves of a pair name the same task and therefore share one expected tool and one expected result, which is what makes the difference between their scores attributable to the language and nothing else. The contract rejects a pair whose two sides are the same string: a copied prompt still scores twice, and would report a comparison it never made.

score-traces.js accepts captured model traces in the documented reference shape and reports tool-selection accuracy, first-attempt generation rate, end-to-end semantic success, missing traces and irrelevant-tool rate — overall, per language, and as a languageGap between the two. Selection and generation are separate numbers because they have different repairs: a model that reaches for the wrong tool with correct source has a tool problem, and one that reaches correctly and writes source computing the wrong thing has a language problem. Generation is rated over the positive cases only, since a case whose correct answer is no call has nothing to generate. positiveSelectionAccuracy is there for the same reason from the other side: toolSelectionAccuracy mixes the two classes, so growing the negative set moves it without any behaviour changing. Each score carries a composition block naming how many cases of each class it was computed over — rates over one class survive a corpus that grows, rates over both only compare within one composition.

A turn may hold several tool calls, and all of them are recorded and scored. A model that looks a Word up and then computes has made one attempt containing two calls, not a wrong choice — 91 of 130 turns in the first baseline did exactly that, so keeping only the first call scored the lookup as the model's decision and reported 0.323 selection accuracy where reading the whole turn reports 0.469. reachedExpectedToolFirstRate reports the stricter reading beside it, without making instinct a pass criterion.

The selection reference fixture is built from the corpus in memory (score-traces.js --reference) rather than committed. A perfect fixture is the corpus answering itself with its own reference arguments, so maintaining 130 of them by hand only meant that adding a case failed --require-perfect for a reason unrelated to the scorer it asserts, and a generated copy only moved that drift into a check.

Every trace document declares what produced it. provenance.source is either referenceFixture — a trace built to pass the scorer, whose perfect result describes the scorer and nothing else — or model, a real capture, which must additionally record the model id, prompt-template digest, tool-choice setting, capture time, and the server, engine and registry versions it ran against. A document without that block is rejected rather than scored, because the same numbers mean "the harness works" or "the model performs this well" depending on an answer the file was not carrying. The scorers print the provenance alongside the metrics, so a score copied out of a log still says which it is.

--require-perfect is only valid on a referenceFixture. It asserts that the scorer runs end to end; pointing it at a model trace would turn the first clean run into a committed claim that the model is perfect, which is the one thing this corpus is least entitled to say. A model trace is scored and reported, never asserted.

Model baselines have been captured and are committed under eval/traces/: claude-opus-5-full-corpus.json and claude-opus-5-repairs-full-corpus.json, the full-corpus pair. The intermediate captures taken while the tool descriptions were being tuned (baseline, after-syntax-rules, after-negatives, after-entry-surface, and their repair counterparts) were superseded by that pair and removed; git history holds them. npm run eval:capture drives a real model over the server's tools — one call per corpus case per language, tool_choice: auto so the irrelevant-intent cases can correctly produce no call — and writes a model trace under eval/traces/, kept apart from the committed fixtures so no directory listing presents the two as the same kind of artifact. It resolves credentials the way the Anthropic SDK does (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or an ant auth login profile) and, finding none, exits non-zero having written nothing. capture-traces.test.js exercises the harness against a scripted client; it tests prompt assembly and tool-call extraction, not a model. npm run eval:capture-repairs captures the other half: for each repair case it asks, executes the model's call against the real server, hands the whole structured result back as a tool_result, and records the second attempt. Both attempts are recorded as calls, never as outcomes — the scorer replays them itself, because a capture that recorded its own verdict would be grading the model with the code that produced its answer. A turn that calls nothing, or a model that gives up after reading the diagnosis, is recorded rather than dropped: a harness that could only capture the runs that went well would report a repair rate computed over those.

score-repairs.js replays a failed attempt and its model-produced revision, requires the expected structured diagnosis before the revision can count, and reports diagnosis-observation and diagnosis-driven repair rates, per language. It replays whichever tool the model chose, not only compute: 1 2 AD through check returns the identical diagnosis, so replaying one tool scored a model that checked before running as never having seen a diagnosis at all. The cases cover unknown Words, stack shape, malformed source and the collectionWork ceiling — the last of these exists to make a claim testable: a ceiling named for collections should send a repair at the collection, and its source contains no arithmetic, so a repaired attempt that succeeds can only have shrunk the collection. Their reference trace is also a scorer fixture, not evidence of model performance. When a refusal comes from a ceiling charged as the operation proceeds — the collection scans — diagnosis.resourceLimit carries a progress { completed, total, unit } alongside observed. It exists because observed cannot serve there: such a meter stops the instant the budget is crossed, so it reads a hair over the limit however far over the request was, and a reader taking it proportionally under-corrects wildly. completed is the size that fits. Measured against a real model, adding it moved the diagnosis-driven repair rate from 0.750 to 1.000 on the same corpus.

eval:validate rejects duplicate or unknown case IDs, unknown tools, malformed JSON pointers and incomplete committed reference traces before scores are calculated. This prevents malformed or selectively omitted traces from silently producing plausible metrics. eval:performance measures five post-warmup rounds over seven representative compute, check, inference and registry cases. It reports p50/p95/max latency by tool and fails when the overall p95 exceeds the committed one-second local stdio budget. It also reports median and maximum response size — for the whole result and for its text block alone — and fails against medianResponseBytesBudget; response bytes are deterministic for a fixed corpus and engine, so unlike the latency figures that gate is exact and reproducible. The latency measurements describe this adapter and machine, not remote service latency. eval:number-baseline compares canonical results for five selected rational, decimal and integer operations with JavaScript Number. It includes two exactly representable controls as well as known precision-sensitive cases, and labels its scope explicitly; it is not a general JavaScript or CAS benchmark. npm run test:pack creates the allowlisted tarball and installs it into an empty temporary prefix, then exercises that installed copy four ways: importing createServer with neither AJISAI_REPO nor AJISAI_BIN set, proving it computes through its packaged, self-contained WASM backend with no repository and no native binary in reach; launching the ajisai-mcp-server bin through node_modules/.bin, which is how every documented client entry starts it; running --doctor on the installed package; and finally spawning that bin again with an explicit AJISAI_BIN, asserting mcp.backend.kind is nativeCli.

The last two of those are spawned processes on purpose. The backend is resolved once per process, so setting AJISAI_BIN and constructing a second server in the same process reused the WASM backend the first scenario had already fixed — the native assertion passed on machines with no native binary at all. Because it is now real, npm run test:pack needs one built; npm run test:mcp-pack from the repository root builds it first.

The browser playground is independent of this package and remains available.

Source: tools/mcp-server/README.md at commit 6b19068

Tools

0
Tool metadata has not been indexed yet.

Version history

1
  1. v0.7.0LatestOct 1, 2026