Skip to main content
Glama
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)