cd /news/developer-tools/prolog-knowledge-base-over-http-json… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-123112] src=github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Prolog knowledge base over HTTP/JSON API, with an OpenAPI description

Charon, a new open-source tool, exposes Prolog knowledge bases as HTTP/JSON APIs or MCP servers, with an OpenAPI 3.1 description, using an embedded scryer-prolog engine. It allows any predicate documented with a PlDoc comment to be callable over either interface, and supports persistence, CI checks, and OpenAPI export. The project is built with Rust and requires a recent stable toolchain.

read5 min views3 publishedSep 8, 2026
Prolog knowledge base over HTTP/JSON API, with an OpenAPI description
Image: Michielbdejong (auto-discovered)

Charon exposes a Prolog knowledge base as an API. A .pl file is loaded into an embedded scryer-prolog engine; any predicate documented with a PlDoc %! comment becomes callable over whichever interface you start:

  • charon http β€” anHTTP/JSON API , described by an OpenAPI 3.1 document at/openapi.json
  • charon mcp β€” anMCP server , over HTTP atPOST /mcp or over stdio with--stdio

Both are rendered from one description of one operation set, so a predicate exposed on either is exposed identically on the other. Which one a process speaks is chosen when it starts, so what a port answers is a property of how you launched it rather than something a client discovers by probing.

  • A recent stable Rust toolchain (edition 2024).
  • Network access on first build: scryer-prolog is a git dependency compiled from source, which takes a while.
cargo build
charon http kb.pl                     # REST + /openapi.json on 127.0.0.1:3000
charon http kb.pl --port 8080         # a different port
charon http kb.pl --host 0.0.0.0      # reachable from other machines (see below)
charon mcp  kb.pl                     # MCP at POST /mcp on 127.0.0.1:3000
charon mcp  kb.pl --stdio             # MCP over stdin/stdout, for a desktop client
charon http kb.pl --persist           # write assertz/retract changes back to the file
charon check kb.pl                    # load, report, exit β€” for CI
charon openapi kb.pl > openapi.json   # print the document and exit

--persist works on every subcommand. --stdio exists only under mcp, and cannot be combined with --host/--port.

The default bind address is loopback. There is no authentication: passing --host 0.0.0.0 makes the whole knowledge base queryable by anyone who can reach the port, and Charon logs a warning when you do.

Diagnostics go to stderr on every subcommand β€” including openapi, so redirecting stdout gives you a document and not a document with a log line in it. --log takes a tracing filter (--log debug, --log charon=trace,warn); RUST_LOG overrides it.

If the knowledge base fails to load, Charon exits rather than starting: a failed consult in scryer leaves every predicate undefined, so a server that started anyway would answer nothing.

{
  "mcpServers": {
    "charon": {
      "command": "/path/to/charon",
      "args": ["mcp", "/path/to/knowledge_base.pl", "--stdio"]
    }
  }
}

Tool names are the predicate names. A get_/ put_/ delete_ prefix becomes a readOnlyHint/ idempotentHint/ destructiveHint annotation rather than being stripped. The knowledge base's source is offered as a resource at charon://source.

Any predicate you want exposed needs a PlDoc mode line directly above its clauses:

%! pim_check(+Age:int, +Drugs:list(atom), -Substance:atom, -Reason:string) is nondet.
%
%   Enumerates one solution per criterion triggered by a patient's medication list.
%
%   @arg Age Age in completed years.
%   @arg Drugs The substances to check.
%   @arg Substance The substance a criterion fired on.
%   @arg Reason Why it fired.
pim_check(Age, Drugs, Substance, Reason) :-
    Age >= 65,
    member(Substance, Drugs),
    pim(Substance, Reason).

The declared type decides how JSON becomes a Prolog term, in both directions and at every depth.

Declared JSON Prolog
atom string 'value' β€” unifies with plain facts likedrug(aspirin)
string (ortext ) string "value" β€” a character list
int /integer number integer
float /number number float
bool /boolean boolean true /false
list(T) array list of T , recursively
list array same as list(any)
any any scalar or array strings become atoms, numbers stay numbers

Declaring the element type is what lets ?Drugs=["diazepam"] unify with ordinary atom facts. Only + (input) and - (output) modes can be exposed; ? and @ cannot, and neither can compound, stream, or a custom type. If a documented predicate does not appear, the server says why at startup β€” charon check kb.pl prints the same report without binding anything.

A zero-arity predicate is written without parentheses: %! ready is semidet.

GET and DELETE take their arguments in the query string, POST/ PUT/ PATCH in a JSON body, and that is what the OpenAPI document describes. At runtime both are accepted for every method, so curl -G and curl --json both work β€” but giving the same argument twice is an error rather than a silent precedence rule.

A query string has no types, so ?Age=82 is read as an integer because Age was declared int. A JSON body is already typed and is passed straight through.

One solution is shaped by the predicate's output arguments:

  • no output arguments β†’true
  • one output argument β†’ that value, unwrapped
  • several β†’ an object keyed by argument name, in the order the mode line declares them

Whether the response is that solution or an array of them follows the declared determinism, not how many solutions turned up:

Declared Response
det the solution
semidet the solution, or null (false if there are no output arguments)
nondet ,multi ,failure , undeclared an array, one entry per solution, [] if the query failed

Deciding this from the declaration is what makes it unambiguous. When the shape depended on the answer count, one solution binding [1, 2] and two solutions binding 1 and 2 came back as exactly the same JSON, and no client could tell them apart. It also means the OpenAPI response schema is exact rather than a oneOf covering every shape the runtime might reach.

Charon wraps every generated goal in catch/3, so a Prolog exception is reported as an error response and the interpreter stays usable. Knowledge bases do not need their own guards.

Arguments are interpolated into generated Prolog source, and every value is escaped β€” quotes, backslashes and control characters included β€” so text that closes its own quote comes back as text rather than being executed.

A /** <module> Title ... */ comment anywhere in the file sets the OpenAPI info block and the MCP server identity.

With --persist, assertz and retract against a :- dynamic(name/arity). predicate are mirrored back into the source file after every call. Only single-line ground fact clauses are rewritten; rules, multi-line clauses and anything else are left exactly as written. The file is replaced atomically (write to a sibling temporary file, then rename), so an interrupted write cannot leave a truncated knowledge base. Charon checks the file is writable at startup rather than after the first change.

cargo test
cargo test exception_does_not_poison_later_requests

MIT β€” see LICENSE.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @charon 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/prolog-knowledge-bas…] indexed:0 read:5min 2026-09-08 Β· β€”