Skip to main content
Glama

jev-mcp

A small, self-contained MCP service that exposes three bounded TypeSafe Jev / System One decision primitives — jev_choice, jev_score, jev_noul — plus jev_decide for batching several of them against one shared state in a single upstream call, over remote HTTPS/MCP.

Open WebUI ──────┐
Codex ───────────┤
Claude Code ─────┼── HTTPS + Bearer auth ──> jev-mcp (Streamable HTTP) ──> TypeSafe Jev API
DocIntel ────────┤
Other agents ────┘

Jev is used here for bounded decisions, not general text generation. Callers are responsible for gathering evidence (web search, DB lookups, etc.) and passing it in as state — this service never does its own research.

Why these design choices

  • Python + FastMCP, Streamable HTTP transport — matches the other MCP services already running on this host and is the current (non-deprecated) MCP HTTP transport.

  • No database, no queue, no OAuth server. Machine-to-machine auth is a single shared bearer token, compared in constant time. This is an experimental, low-traffic internal service — that's the right amount of mechanism for now.

  • A dedicated Caddy sidecar, not the host's existing Caddy instance. This host already runs a Caddy process (under a different local user, proxying Ollama) — reusing it would mean editing someone else's live shared config for an unrelated purpose. The sidecar here is fully self-contained and can't conflict with it.

  • No public DNS / Let's Encrypt. Access to this service is via Tailscale/WireGuard only, not the public internet, so Caddy terminates HTTPS with its own locally-trusted CA (tls internal) instead. See HTTPS below for what that means for clients.

Related MCP server: Jev MCP

Repository layout

jev-mcp/
├── src/
│   ├── server.py         # FastMCP app: the 4 tools + /health route + auth wiring
│   ├── jev_client.py      # Thin async HTTP client for the TypeSafe System One API
│   ├── auth.py             # Bearer-token middleware protecting the MCP endpoint
│   ├── config.py           # Env-var settings, fails fast if secrets are missing
│   └── logging_setup.py    # One-line structured JSON logging per request
├── tests/                 # pytest unit tests (client, auth, tools)
├── scripts/test_client.py  # Manual connectivity + example-decision test client
├── deploy/
│   ├── deploy.sh            # Parameterized remote deploy (Oracle Cloud / any SSH host)
│   └── bootstrap_remote.sh   # Remote-side: installs Docker, opens firewall (idempotent)
├── caddy/
│   ├── Caddyfile            # Local/Tailscale variant (self-signed, built into an image)
│   ├── Caddyfile.public      # Public variant (auto Let's Encrypt, bind-mounted)
│   └── Dockerfile            # Builds the Caddyfile-baked image used by docker-compose.yml
├── Dockerfile
├── docker-compose.yml       # Local deploy (Tailscale/WireGuard-only, self-signed)
├── docker-compose.prod.yml   # Public deploy (real domain, Let's Encrypt)
├── .env.example
├── LICENSE
├── CONTRIBUTING.md
└── README.md

Quickstart

cp .env.example .env
# edit .env: set TYPESAFE_API_KEY (your real TypeSafe key) and
# MCP_AUTH_TOKEN (generate with: openssl rand -hex 32)

docker compose up -d --build
docker compose ps          # both jev-mcp and jev-mcp-caddy should be healthy/running
curl -s http://127.0.0.1:9020/health   # {"ok":true,...} — local, unauthenticated

Then from a remote machine on your Tailscale/WireGuard network:

curl -sk https://<this-host-tailscale-ip>:8443/health

The MCP endpoint

https://<host>:8443/mcp

All requests to /mcp require Authorization: Bearer <MCP_AUTH_TOKEN>. GET /health is unauthenticated (for container/orchestrator health checks) and only ever returns {"ok": true/false, ...} — no secrets.

Fill in your actual reachable address once deployed (e.g. https://100.64.1.2:8443/mcp if using this host's current Tailscale IP — check with tailscale ip on the host, this can change if the machine is re-added to the tailnet).

Tools

All tools return {"ok": true, "request_id", ...} on success or {"ok": false, "request_id", "error": {"type", "message", ...}} on failure — including upstream Jev failures. Nothing is ever silently fabricated: if Jev fails or omits a field (e.g. Noul has no confidence), the response reflects that rather than inventing a value.

jev_choice — pick one of a fixed set of options

// input
{
  "state": "AAPL: growth=high, valuation=high\nMSFT: growth=medium-high, valuation=medium-high\nGOOG: growth=medium-high, valuation=medium",
  "question": "Which best matches high growth with reasonable valuation?",
  "options": ["AAPL", "MSFT", "GOOG"]
  // optional: "criteria": {"AAPL": "...", "MSFT": "...", "GOOG": "..."}
}
// output
{
  "ok": true, "request_id": "...", "model": "jev-1.13.0", "usage": {...},
  "type": "choice", "choice": "MSFT", "confidence": 0.7,
  "probabilities": {"AAPL": 0.2, "MSFT": 0.7, "GOOG": 0.1}
}

jev_score — rate something against ordered levels

// input
{
  "state": "The export button crashes the settings page in Safari.",
  "question": "How severe is the reported issue?",
  "criteria": ["Cosmetic; no functional impact", "Degraded feature, workaround exists", "Blocking; no workaround"]
}
// output
{
  "ok": true, "type": "score", "score": 1.43, "confidence": 0.35,
  "probabilities": {"0": 0.0, "1": 0.57, "2": 0.43},
  "legend": {"0": "Cosmetic...", "1": "Degraded...", "2": "Blocking..."}
}

jev_noul — bounded yes/no

// input
{"state": "I have asked three times now. Can I please just talk to a real person?",
 "question": "Is the customer asking for a human agent?"}
// output
{"ok": true, "type": "noul", "noul": 0.99}
// no "confidence" field — Jev doesn't return one for Noul, and none is invented here

jev_decide — batch several questions against one state, one upstream call

Use this instead of multiple jev_choice/jev_score/jev_noul calls when you need more than one judgment about the same evidence. The TypeSafe API natively supports multiple typed questions per request; this tool is the only one that uses that instead of forcing N round trips.

// input
{
  "state": "...",
  "decisions": [
    {"id": "dept", "type": "choice", "question": "Which team should handle this?", "options": ["returns", "shipping", "billing"]},
    {"id": "urgent", "type": "noul", "question": "Does this need same-day attention?"}
  ]
}
// output
{"ok": true, "request_id": "...", "usage": {...}, "results": {"dept": {...}, "urgent": {...}}}

Environment variables

See .env.example for the full annotated list. Required: TYPESAFE_API_KEY, MCP_AUTH_TOKEN (service refuses to start without either). Everything else has a sane default.

JEV_LOG_STATE=true enables verbose debug logging that includes the raw state payload — off by default since state may contain private information.

Logging

One JSON line per tool call to stdout (docker compose logs jev-mcp):

{"ts": "...", "tool": "jev_choice", "request_id": "...", "decision_type": "choice", "success": true, "jev_latency_ms": 340.2, "upstream_status": 200, "usage": {"input_tokens": 62, "output_tokens": 9}, "confidence": 0.7}

state content is never included unless JEV_LOG_STATE=true.

Error handling

Condition

Result

TYPESAFE_API_KEY / MCP_AUTH_TOKEN missing

Service refuses to start (config error logged, process exits)

Invalid tool input (e.g. 1 option for a choice)

{"ok": false, "error": {"type": "ValueError", ...}}, no upstream call made

Jev returns 401/403

{"ok": false, "error": {"type": "JevAuthError", "upstream_status": 401}}

Jev returns 429

{"ok": false, "error": {"type": "JevRateLimitError", "retry_after_seconds": ...}}

Jev request times out

{"ok": false, "error": {"type": "JevTimeoutError"}} (default 30s, TYPESAFE_TIMEOUT_SECONDS)

Malformed/non-JSON Jev response

{"ok": false, "error": {"type": "JevUpstreamError"}}

Network failure reaching Jev

{"ok": false, "error": {"type": "JevConnectionError"}}

Missing/wrong MCP bearer token

HTTP 401 at the transport layer, before any tool runs

HTTPS

Caddy (the jev-mcp-caddy sidecar) terminates TLS on :8443 using its own internal CA (tls internal in caddy/Caddyfile) — no public domain or Let's Encrypt involved, matching an access pattern that's Tailscale/WireGuard-only.

Two things worth knowing:

  1. The Caddyfile lists specific hostnames/IPs (localhost, 127.0.0.1, and the host's Tailscale IP) that it issues certificates for at startup, plus a default_sni so IP-literal connections (which typically send no SNI at all — this is how most MCP/HTTP clients connect over Tailscale) still get a valid cert. If this host's Tailscale IP changes, update that list and run docker compose up -d --build caddy.

  2. Clients will see an untrusted-certificate warning unless they trust Caddy's local root CA. For a private, Tailscale-only deployment this is normally fine to bypass (-k in curl, verify=False in the test client) since the transport is already encrypted/authenticated by WireGuard. To avoid the warning instead, export Caddy's root and trust it on client machines:

    docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > jev-mcp-ca.crt
    # then install jev-mcp-ca.crt in each client's trust store

Upgrade path to a publicly-trusted cert (optional, not required for the current access pattern): if this tailnet has Tailscale HTTPS Certificates enabled, you could instead run tailscale cert for this node's *.ts.net name and point Caddy at those files — real Let's Encrypt-backed certs, still no public exposure needed. Not set up here since it requires an admin-console change on your tailnet, which is your call to make.

The internal app (jev-mcp, port 9020) is only bound to 127.0.0.1 on the host — it is never reachable except through the Caddy sidecar or from the host itself.

Deploying to a public host (e.g. Oracle Cloud)

The Mac mini setup above is IP/Tailscale-only. On a real public cloud VM you have a public IP, so a real, publicly-trusted certificate is both possible and the better choice: Caddy obtains and auto-renews one from Let's Encrypt on its own — no manual cert files, no CDN/proxy in front.

This uses a different compose file (docker-compose.prod.yml) and Caddyfile (caddy/Caddyfile.public) than the Mac mini setup — stock caddy:2-alpine with the Caddyfile bind-mounted in (no custom image build needed; the earlier "bake the Caddyfile into the image" workaround was specifically for this Mac's Colima setup, which only auto-mounts $HOME — a normal Linux Docker host doesn't have that restriction).

One-time setup, before running the script

  1. Provision the VM (you said you already have this) with SSH key access. Ubuntu and Oracle Linux are both supported by the bootstrap script.

  2. DNS: add an A/AAAA record for your domain pointing directly at the VM's public IP — DNS-only, not proxied through Cloudflare or any other CDN (no orange cloud). Caddy needs to see real inbound connections itself to complete the Let's Encrypt domain-ownership challenge.

  3. Cloud-level firewall: make sure the instance's Security List (or Network Security Group, if separately configured — check both) has ingress rules allowing TCP from 0.0.0.0/0 on port 80 (needed for the ACME challenge, even though the service itself is reached over HTTPS) and your chosen --https-port (443 by default). This is a cloud-console setting the deploy script cannot reach from here — it only configures the VM's own OS firewall.

Deploy

deploy/deploy.sh \
  --host <vm-ip-or-hostname> \
  --user ubuntu \
  --domain jev.yourdomain.com \
  --acme-email you@example.com
  # optional: --ssh-key ~/.ssh/id_ed25519  --remote-dir /opt/jev-mcp  --https-port 443
  # optional: --private-http-host 10.x.x.x  --private-http-port 8080   (see below)

This is idempotent — re-run it any time to push code updates; it will rsync the latest files, refresh .env, and restart the stack. It:

  1. Bootstraps the VM over SSH (installs Docker + Compose plugin + rsync if missing, opens the OS firewall for ports 80 and --https-port) via deploy/bootstrap_remote.sh.

  2. rsyncs this project to <remote-dir>/app.

  3. Writes a remote .env — reusing TYPESAFE_API_KEY/MCP_AUTH_TOKEN from your local .env if present (generating a fresh MCP_AUTH_TOKEN otherwise), plus DOMAIN/CADDY_PORT/ACME_EMAIL.

  4. Runs docker compose -f docker-compose.prod.yml up -d --build on the VM.

  5. Curls https://<domain>:<port>/health from your machine (retrying for ~30s — first-time cert issuance isn't instant) to confirm it's actually live before declaring success.

At the end it prints the MCP URL (https://<domain>:<port>/mcp) and the auth token to use.

Private/direct path (no public DNS dependency): pass --private-http-host <ip> (e.g. a WireGuard or Tailscale address already reachable from your machine) to also have Caddy serve plain HTTP on that IP, alongside the public HTTPS path — see the second site block in caddy/Caddyfile.public. No TLS there since the private tunnel already provides transport encryption; bearer-token auth still applies regardless of transport. The port is published bound to exactly that IP (never 0.0.0.0), so it's never reachable publicly, and it defaults to 8080 (not 80) so it doesn't collide with the public ACME listener. This is genuinely useful as a fallback for whenever the public path is unavailable for any reason.

Diagnostic history worth knowing about (both encountered on the Oracle Cloud deployment this was built against, neither is a flaw in this project's config):

  • A cloud provider's network can silently drop a CDN's traffic on the standard port while leaving direct connections untouched. The original design here used a Cloudflare-proxied setup with a Cloudflare Origin Certificate. It turned out Oracle Cloud's network was silently dropping Cloudflare's edge traffic on port 443 specifically — every console setting (Security List, DNS, SSL mode) was correct, curl from an ordinary machine reached the origin fine, but Cloudflare's edge got 523 (origin unreachable), and Caddy's own access log showed zero requests ever arriving from Cloudflare's IP ranges (confirmed by watching docker compose -f docker-compose.prod.yml logs -f caddy live while triggering requests through the public URL — real diagnostic signal, not a config guess). Switching to a non-standard port worked around it, but going direct (no proxy in front at all, this repo's current approach) sidesteps the whole class of problem, since only the CDN's shared IP ranges were being filtered — direct visitor traffic was never affected.

  • A VM's own outbound DNS resolution can silently break independent of anything in this repo. Oracle's link-local metadata DNS relay (169.254.169.254) started failing mid-session, breaking docker pull/apt with "server misbehaving" errors. If deploy.sh's Docker build step fails on a DNS-resolution error, check the VM can resolve names at all (getent ahosts registry-1.docker.io) before assuming it's a project issue — fix with a static /etc/resolv.conf pointing at a public resolver (e.g. 8.8.8.8/1.1.1.1) if needed.

This was validated end-to-end against a real Oracle Cloud Ubuntu 24.04 VM in this session (deployed, real Jev decisions through both the public HTTPS path and the private WireGuard path, auth enforcement on both) — under the earlier Cloudflare-based design; the switch to direct Let's Encrypt was made for the architectural reasons above and validated with caddy validate/docker compose config locally, but treat the very first live run of this exact flow as worth watching.

Testing

Unit tests

pip install -r requirements-dev.txt
pytest -q

26 tests covering: config validation (fails fast on missing secrets), the Jev client's request shape and error-mapping for every documented failure mode, the auth middleware, and all four tools' input validation / success / failure paths against a mocked upstream.

Manual / remote connectivity test

python scripts/test_client.py --url https://<host>:8443/mcp --token <MCP_AUTH_TOKEN> --insecure

(Drop --insecure once you've installed Caddy's root CA locally, per HTTPS.) This checks /health, lists the registered tools, and runs the example decision from the project spec:

state:    AAPL/MSFT/GOOG growth & valuation notes
question: Which best matches high growth with reasonable valuation?
options:  AAPL, MSFT, GOOG

This is a connectivity/decision smoke test only — it does not fetch real market data (by design; Jev doesn't do its own research).

Open WebUI integration

Open WebUI's tool servers currently speak OpenAPI, not raw MCP — connecting an MCP server means bridging it through mcpo (the Open WebUI project's own MCP→OpenAPI adapter), rather than building a custom bridge for this service. (This wasn't verified against a live Open WebUI install in this session — no instance was running on this host — so double-check the exact admin-settings labels against your installed version.)

  1. Run mcpo pointed at this remote MCP server, forwarding the bearer token as an upstream header:

    pip install mcpo
    mcpo --port 8001 --api-key "<a separate key for Open WebUI to call mcpo with>" \
         --server-type "streamable-http" \
         --header '{"Authorization": "Bearer <MCP_AUTH_TOKEN>"}' \
         -- https://<host>:8443/mcp

    Or via a config file (mcpo --config mcpo.json):

    {
      "mcpServers": {
        "jev-mcp": {
          "type": "streamable-http",
          "url": "https://<host>:8443/mcp",
          "headers": {"Authorization": "Bearer <MCP_AUTH_TOKEN>"}
        }
      }
    }
  2. In Open WebUI: Settings → Admin Settings → Tools, add an OpenAPI tool server pointing at http://<mcpo-host>:8001 with the mcpo --api-key you chose above.

  3. Enable the jev-mcp tools for the model(s) you want to use them, alongside a web-search tool. The intended flow per the project spec:

    search_web → collect evidence → jev_choice / jev_score / jev_noul → answer user

mcpo never sees TYPESAFE_API_KEY — only the MCP_AUTH_TOKEN you configured, which is exactly the credential jev-mcp expects.

Other clients (Codex, Claude Code, DocIntel, custom agents)

Nothing about this service is Open WebUI-specific — it's a standard MCP Streamable HTTP server with bearer auth. Any MCP-capable client can connect directly to https://<host>:8443/mcp with Authorization: Bearer <MCP_AUTH_TOKEN>, no adapter needed. scripts/test_client.py doubles as a minimal reference for wiring up a Python client (fastmcp.Client + StreamableHttpTransport).

Operations

  • Restart policy: both containers use restart: unless-stopped; Colima (this host's Docker VM) is already configured to start on login via launchd, so the stack comes back up automatically after a reboot.

  • Updating the Jev API key: edit .env, then docker compose up -d (no rebuild needed — it's read from the environment at process start).

  • Rotating the MCP auth token: same — edit MCP_AUTH_TOKEN in .env, restart, and update every client's config.

  • Logs: docker compose logs -f jev-mcp / docker compose logs -f caddy.

Contributing

See CONTRIBUTING.md — setup, running tests, and what's in/out of scope for this project.

License

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP hosts to query Jev's typed decision model—yes/no, choice, and score—with calibrated probabilities, while defaulting to an offline mock and disclosing all egress unless explicitly enabled.
    13
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables MCP clients to submit bounded semantic-uncertainty judgments to the pinned TypeSafe Jev API, with tools for yes/no, choice, and score evaluations plus optional evidence or context selection.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP-capable harnesses to apply TypeSafe/Jev's System One decision model through judgment primitives and task-shaped tools, with local event logging and Prometheus metrics for impact tracking.
    1
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables MCP-compatible agent hosts to interact with TypeSafe AI's Jev System One API through dependency-free STDIO tools for classification, scoring, verification, gating, routing, review, and health checks.
    9
    MIT