The two MCP servers (don't confuse them)
/api/v1/mcp— always on. Registry-generated since mos-7kajg.37: the original 10 tools (list_tables,query_rows,insert_row,list_forms,list_bases,list_workflows,create_invoice,create_quote,create_checkout,create_subscription— now tokenOnly alias actions insrc/lib/server/actions/v1Tools.ts, byte-compatible names + schemas) plus every other registereddefineActionthat declares a token scope resource (e.g. the task actions withproject_task:read|write). tools/list is filtered by the caller's token scopes — atables:read-only token sees only the tables read tools — and write/delete calls additionally require themodules.mcp_exportflag (mcp*export is the WRITE gate only — except for an OAuth-connected app, whose gate is the team's Connected apps opt-in). Writes route throughexecuteCommandwith the trust model (service executes; an OAuth-connected app executes unless the team'smodules.agent_connect.write_modeispropose; a pasted human key proposes — mos-zase8). This is what an external Claude/Cursor config points at. Auth:Bearer jst*\*. Verified working live — a minted key resolves and reachestools/call./api/v1/mcp/actions— write-gated bymodules.mcp_export(off by default: writes/deletes → isError; read-scope actions still callable with the right token scope; tools/list shows only read actions while off). Exposes every registereddefineActionwith the full module-gate + scope + approval semantics: service tokens execute immediately (recorded changeset); an OAuth-connected app follows its team's write mode (direct by default); pasted human/break-glass tokens are forced to propose mode (queued for human review). This is the richer, safer surface — but writing needs the tenant opt-in.
On the 401 that stranded the MCP configs: the header-auth path is correct.
The 401 {code:-32000} seen this session came from an empty token env
(MOS_MCP_TOKEN unset → the client sent Bearer with no value). The 401 now
returns a diagnostic message naming the exact cause (fixed in
mos-hq6q4.76). See quickstart § "MCP config".
Connecting an outside agent (the doors)
Every harness connects to the same tool registry — the defineAction
registry — served through three doors. There is no per-vendor tool list:
a tool added to the registry appears on every door, filtered by the caller's
token scopes and the team's enabled modules.
| Harness | How | URL |
|---|---|---|
| ChatGPT | custom MCP connector, OAuth | https://<team>.marduk.app/api/v1/mcp |
| Claude.ai | custom connector, OAuth | same URL |
| Claude Code | claude mcp add --transport http — OAuth or a Bearer jst_ header |
same URL |
| Codex | mos CLI, or native MCP with a bearer token |
scripts/mos-cli/ or same URL |
| pi | mos CLI + the mos skill |
scripts/mos-cli/ + docs/agent-door/skills/mos/SKILL.md |
| Any OpenAI-compatible API (Kimi, Z.ai, SambaNova, OpenRouter…) | function-calling door | GET /api/v1/tools?format=openai|anthropic|gemini|mcp + POST /api/v1/tools/call |
The gate (Joe's risk rules, enforced in code):
- Opt-in per team by an owner/admin — Admin → Settings → Connected apps
(
modules.agent_connect.enabled, default OFF). Off means every door answers403 agent_connect_disabled. - A user can only grant what they can already see — the OAuth consent page
(
/oauth/authorize) offers four tool presets (Projects & knowledge, Tables, Calendar, Read-only everything), each capped at the consenting member's own team role; a viewer's grant has every write stripped. Messaging, money, health records and secrets are in no preset (asserted bypresets.test.ts). - Isolated/HIPAA tenants: hard 404/403, never a warning — an isolated
container (
PRIVATE_TENANT_ISOLATED/PRIVATE_AUTH_COOKIE_PREFIX) serves no OAuth route, no discovery document, no consent page, and the function-calling door answersisolated_tenant. BAA teams are never offered at consent either. - Tokens are scoped, expiring, revocable — OAuth access tokens live 1h,
refresh tokens 30d single-use (replaying a rotated one revokes the grant);
Connected apps → Disconnect kills the grant and every token it issued.
An OAuth-connected app's writes follow the team's write mode
(Connected apps page): Read + write (default) executes each one as the
member who connected it — a recorded changeset, attributed and undoable from
Admin → Changesets, with
changedin the result; Read + propose queues each one for an admin (queued: true+changeset_url). - Nothing leaves MOS without an explicit confirmation — the exposure
ledger (
src/lib/server/actions/tokenExposure.ts) classifies every token-exposed action:internalis never listed or callable;confirm(sends outside MOS, moves money, deletes, admin/provisioning) advertisesconfirm: trueand the dispatcher refuses the call without it.
Per-harness setup guides (steps, admin enablement, presets, revoke,
data-leaves-MOS, HIPAA note) live in the repo at docs/agent-door/ —
README.md plus one page per harness: connect-chatgpt.md,
connect-claude-ai.md, connect-claude-code.md, connect-codex.md,
connect-pi.md, connect-openai-compatible.md. The admin surface is
Admin → Settings → Connected apps (/admin/settings/connected-apps).
3. MCP config (Claude / Cursor / any MCP client)
MOS speaks MCP over plain HTTP (JSON-RPC 2.0) at /api/v1/mcp — 10 tools:
list_tables, query_rows, insert_row, list_forms, list_bases,
list_workflows, create_invoice, create_quote, create_checkout,
create_subscription.
{
"mcpServers": {
"mos": {
"type": "http",
"url": "https://acme.marduk.app/api/v1/mcp",
"headers": { "Authorization": "Bearer jst_YOUR_TOKEN_HERE" }
}
}
}
The #1 gotcha: an empty token → 401 {code:-32000}. If you template the
token from an env var ("Bearer ${MOS_MCP_TOKEN}") and the var is unset, the
client sends Bearer with no value and every call 401s. The server now tells
you exactly which case you hit ("empty Bearer token…", "not a MOS team API
key…", "invalid, revoked, or expired"). Sanity-check with curl before wiring a
client:
curl -s -X POST https://acme.marduk.app/api/v1/mcp \
-H "Authorization: Bearer $MOS_MCP_TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# → {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"jst-client-api",...}}}
The URL must include /api/v1/mcp. A bare host (https://acme.marduk.app)
gives the client's own ENDPOINT_NOT_FOUND.
There is a second, richer MCP server at /api/v1/mcp/actions that exposes every
MOS action — but it is off by default (per-tenant modules.mcp_export) and
returns 403 until enabled. See agentic-rails.md.
For a person connecting ChatGPT, Claude.ai, Claude Code, Codex or pi, prefer
the OAuth door over a pasted token: the same URL, discovered via
/.well-known/oauth-protected-resource, with a scoped, expiring, revocable
consent grant. See agentic-rails § "Connecting an outside agent"
and the per-harness guides in docs/agent-door/.