jev-mcp
by henrykey
README.md
# jev-mcp
A small, self-contained MCP service that exposes three bounded [TypeSafe Jev / System One](https://docs.typesafe.ai) 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](https://gofastmcp.com)**, 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](#https) below for what that means for clients.
## 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
```bash
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:
```bash
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
```jsonc
// 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
```jsonc
// 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
```jsonc
// 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.
```jsonc
// 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](.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`):
```json
{"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](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:
```bash
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](https://tailscale.com/kb/1153/enabling-https) 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](docker-compose.prod.yml)) and Caddyfile ([caddy/Caddyfile.public](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
```bash
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](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](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
```bash
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
```bash
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](#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](https://github.com/open-webui/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:
```bash
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`):
```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](CONTRIBUTING.md) — setup, running tests, and what's in/out of scope for this project.
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues