Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ These CAS-managed records also have one writer each:
- `IdentityService` owns each identity at `_system/identities/{user-id}.json`.
- `CliAuthorizationService` owns each short-lived CLI login grant at
`_system/cli-authorizations/{authorization-id}.json`.
- `OAuthClientStore` owns each registered client at
`_system/oauth-clients/{client-id}.json`.
- `OAuthAuthorizationService` owns each short-lived MCP OAuth grant at
`_system/oauth-authorizations/{authorization-id}.json`.
- `OAuthRateLimitService` owns each shared MCP OAuth request window at
`_system/oauth-rate-limits/{endpoint}.json`.
- `ProjectAlertStore` owns each project alert configuration at
`projects/{pid}/alerts.json`.
- `SessionService` owns each editor claim at
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,12 @@ export default defineConfig({
},
{
text: 'Reference',
activeMatch: '^/(configuration|api|cli|architecture|agent-guide)',
activeMatch: '^/(configuration|api|api-tokens|mcp|cli|architecture|agent-guide)',
items: [
{ text: 'Configuration', link: '/configuration' },
{ text: 'API & client', link: '/api' },
{ text: 'API tokens', link: '/api-tokens' },
{ text: 'MCP server', link: '/mcp' },
{ text: 'CLI', link: '/cli' },
{ text: 'How it works', link: '/architecture' },
{ text: 'Agent guide', link: '/agent-guide' },
Expand Down Expand Up @@ -205,6 +206,7 @@ export default defineConfig({
{ text: 'Configuration', link: '/configuration' },
{ text: 'API & client', link: '/api' },
{ text: 'API tokens', link: '/api-tokens' },
{ text: 'MCP server', link: '/mcp' },
{ text: 'CLI', link: '/cli' },
{ text: 'How it works', link: '/architecture' },
{ text: 'Agent guide', link: '/agent-guide' },
Expand Down
1 change: 1 addition & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ MARIMOHUB_AUTH_ALLOWED_EMAIL_DOMAINS=example.com # REQUIRED (oidc): comma-separ
# --- Server ---
PORT=3000
MARIMOHUB_STATIC_ROOT=./public # prebuilt SPA assets
# MARIMOHUB_MCP=on # MCP + OAuth server; requires MARIMOHUB_APP_BASE_URL
# MARIMOHUB_EXPERIMENTS=duckdb-wasm-preview,duckdb-wasm-sql
MARIMOHUB_RUN_MAINTENANCE=true # run session-maintenance cron on THIS replica only
MARIMOHUB_MAX_SESSIONS_PER_USER=10 # per-user concurrent session cap (0 = unlimited)
Expand Down
6 changes: 6 additions & 0 deletions apps/server/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ export default defineConfig({
dts: false,
noExternal: [
/^@marimo-hub\//,
/^@modelcontextprotocol\//,
/^hono/,
/^hono-rate-limiter$/,
/^@hono\//,
/^pkce-challenge$/,
/^zod-to-json-schema$/,
/^ajv(-formats)?$/,
/^json-schema-typed$/,
/^jose$/,
/^oauth4webapi$/,
// ofetch + its (pure-JS) deps must be bundled — the runtime image ships
Expand Down
50 changes: 48 additions & 2 deletions development_docs/bucket_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ s3-bucket/
│ │ └── {authorization-id}.json ← short-lived PKCE login grant (CAS-claimed)
│ ├── cli-device-user-codes/
│ │ └── {user-code}.json ← immutable lookup claim for a device grant
│ ├── oauth-clients/
│ │ └── {client-id}.json ← immutable dynamic client registration
│ ├── oauth-authorizations/
│ │ └── {authorization-id}.json ← short-lived MCP OAuth grant (CAS-claimed)
│ ├── oauth-rate-limits/
│ │ └── {endpoint}.json ← shared MCP OAuth request window (CAS)
│ ├── integrations/ ← organization-wide integrations (§4.12)
│ │ ├── _names/
│ │ │ └── {name}.json ← name claim (CAS, app-claim pattern)
Expand Down Expand Up @@ -215,6 +221,9 @@ created for combined workspace and Git inputs up to 32 MiB.
| `_system/tokens/{token-id}.json` | JSON | Personal access token record (`{ id, user_id, name, hash, … }`) keyed by the ULID embedded in the presented `mhub_pat_` bearer, so verification is a single GET. Stores only the SHA-256 of the secret. Mutable, last-writer-wins (coarse `last_used_at` refresh); revocation deletes the object. See §4.11. |
| `_system/cli-authorizations/{authorization-id}.json` | JSON | Ten-minute browser-to-CLI PKCE grant. Stores hashes/challenges, never a PAT or plaintext authorization secret. `CliAuthorizationService` CAS-claims it before minting one token, then deletes it. See §4.11.1. |
| `_system/cli-device-user-codes/{user-code}.json` | JSON | Create-if-absent lookup claim from an eight-letter device user code to its short-lived CLI authorization. `CliAuthorizationService` owns the claim. It deletes the claim after exchange and prunes it after ten minutes. See §4.11.1. |
| `_system/oauth-clients/{client-id}.json` | JSON | Immutable OAuth client registration. `OAuthClientStore` owns each record. Public clients expire after 90 days. See §4.11.2. |
| `_system/oauth-authorizations/{authorization-id}.json` | JSON | Ten-minute MCP OAuth authorization owned by `OAuthAuthorizationService`. Approval stores a one-time code hash with CAS. Exchange claims the record with CAS before it mints one scoped PAT and deletes the record. See §4.11.2. |
| `_system/oauth-rate-limits/{endpoint}.json` | JSON | Bounded sliding-window timestamps for an MCP OAuth endpoint. `OAuthRateLimitService` uses ETag CAS so all serving replicas enforce one deployment quota. Expired timestamps are removed on the next admitted request. See §4.11.2. |
| `_system/events/{YYYY-MM-DD}/{event-id}.json` | JSON | Structured event log. One immutable object per event, keyed by a monotonic ULID under a per-day prefix. Primary audit trail. |
| `_system/events/{YYYY-MM-DD}/_idempotency/{key}.json` | JSON | Create-if-absent mapping from a stable operation key to one event ID and serialized event body. A retry repairs a missing event object from this marker without changing its append position or payload. |
| `_system/idempotency/{digest}.json` | JSON | Recorded `POST`-create response for an `Idempotency-Key`, keyed by `sha256(user:route\nkey)`. Replayed on retry; pruned after 24h. See [`idempotency.md`](./idempotency.md). |
Expand Down Expand Up @@ -638,14 +647,20 @@ A scoped token has this version 2 shape:
"actions": ["project.read", "integration.read", "integration.use", "session.start"],
"projects": ["proj-7h2k9qm4xz7rp3w8"]
},
"oauth": {
"client_id": "01HXY0S6GWMBASVAG3PZ7Y2K5V",
"resource": "https://hub.example.com/mcp",
"scopes": ["mcp:tools"]
},
"created_at": "2026-07-24T14:30:00Z",
"expires_at": "2026-10-22T14:30:00Z"
}
```

`actions` is `"*"` or a unique canonical action list. `projects` is `"*"` or
1 to 100 unique project IDs. Parsing fails for a missing, inconsistent, or
unknown credential version. Other fields remain loose so the daily
unknown credential version. The optional `oauth` object binds an OAuth token to
its client, MCP resource, and scopes. Other fields remain loose so the daily
`last_used_at` rewrite preserves fields from later releases.

**Why keyed by token id.** The presented bearer is
Expand Down Expand Up @@ -690,6 +705,35 @@ Old replicas reject the v2 states. During a mixed rollout, they can reject a
scoped authorization or token. They cannot mint or authenticate it as a legacy
full-access token.

### 4.11.2 MCP OAuth records

`OAuthClientStore` stores immutable public-client registrations under
`_system/oauth-clients/`. Each client uses
`token_endpoint_auth_method: "none"`, has permitted redirect URIs, and expires
after 90 days. A registration request prunes at most 100 expired records.

`OAuthAuthorizationService` stores ten-minute records under
`_system/oauth-authorizations/`. `begin` stores the client, redirect, PKCE
challenge, state, scopes, and resource. Approval uses ETag compare-and-swap
(CAS) to bind the user, token grant, PAT lifetime, and one-time code hash.
The service verifies the bindings, claims the record with CAS, and mints one
scoped PAT. This PAT stores the client ID, exact MCP resource, and `mcp:tools`
scope. The MCP endpoint rejects other PATs. A `finally` block deletes the
authorization. Denial uses CAS to mark the pending record as denied before
deletion. It then redirects with `error=access_denied`.

OAuth PATs expire within 90 days. The consent page defaults to 7 days. The
authorization server does not issue refresh tokens.

`OAuthRateLimitService` owns one bounded CAS record per public OAuth endpoint
under `_system/oauth-rate-limits/`. All replicas share these sliding windows.
CAS contention fails closed as a rate-limit rejection; storage failures remain
server errors.

Dynamic registration is anonymous. Each successful registration emits an
`oauth_client_registered` event with the generated client ID and redirect count.
The event omits client-supplied names and URIs.

### 4.12 `projects/{pid}/integrations/{iid}/…`

A project integration instance — a named, versioned configuration of a code-registered _kind_ (`postgres`, `iceberg_rest`, …) rendered into every session's sandbox as env vars + files. Two records:
Expand Down Expand Up @@ -1154,7 +1198,9 @@ Schema migrations are a first-class concern because there is no database to run
| `_system/events/**` | Never migrated — event records are immutable history. New event shapes get a bumped `schema_version` field. Consumers must handle multiple versions. |
| `catalog.json` | Migrated in place during the first write after a deployment that changes the catalog version. Its strict `version` value is not forward-tolerant. |

> **No `schema_version` by design:** mutable operational records — sessions (`_system/sessions/**`), identities (`_system/identities/**`), tokens (`_system/tokens/**`), CLI authorizations (`_system/cli-authorizations/**`), and `fs_snapshot.json` — carry no `schema_version`. They are rewritten on every write or reaped shortly after creation, so they never need a migration; a shape change is absorbed with optional fields + defaults on read. API response bodies are likewise unversioned per-object — the contract is versioned at the route level (`/api/v1`).
> **No `schema_version` by design:** Operational records have no `schema_version`. This group includes sessions (`_system/sessions/**`), identities (`_system/identities/**`), tokens (`_system/tokens/**`), CLI and OAuth authorizations (`_system/cli-authorizations/**`, `_system/oauth-authorizations/**`), and `fs_snapshot.json`. Writers replace mutable records. Cleanup removes short-lived records. Readers accept compatible changes through optional fields and defaults.
>
> OAuth clients (`_system/oauth-clients/**`) also omit `schema_version`, but they are immutable and retained for 90 days. Readers must remain backward-compatible with records written during that window. API response objects are also unversioned. The route path (`/api/v1`) versions the API contract.

### Migration job pseudocode

Expand Down
3 changes: 3 additions & 0 deletions docs/api-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Personal access tokens (PATs) let CI jobs, scripts, and the CLI call the
`/api/v1/*` HTTP API without a browser session. A token acts as the user who
created it. It cannot add authority that the user does not have.

MCP clients receive scoped PATs through browser consent. See
[MCP server](./mcp.md).

For an interactive CLI, run `mohub login`. For a remote CLI, run
`mohub login --device-code`. Both commands create a 30-day token by default. You
can select a different lifetime in the approval page. Use manual tokens for
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,16 @@ Server-wide settings; no backend selector.
| `MARIMOHUB_VERSION` | Build/deploy version (usually the short git SHA or release tag) shown in the UI footer and returned by `GET /api/v1/version`. Baked into the image at build time. | — | `dev` | `a1b2c3d` |
| `MARIMOHUB_IMAGE` | Fully-qualified Docker image reference (`repo:tag`) the deployment runs, shown in the UI footer. Baked into the image at build time. | — | — | `ghcr.io/marimo-team/marimohub:a1b2c3d` |

## MCP

The built-in OAuth 2.1 server exposes notebooks to MCP clients. MCP is off by default and requires a fixed public base URL. See [MCP server](./mcp.md).

### Server

| Variable | Description | Required | Default | Example |
| --- | --- | --- | --- | --- |
| `MARIMOHUB_MCP` | Enables the MCP endpoint and OAuth server. Requires `MARIMOHUB_APP_BASE_URL`. Values: `on`, `off`. | — | `off` | `on` |

## Jobs

Headless notebook runs on a cron schedule or on demand, with a durable run history. Off unless `MARIMOHUB_JOBS=on`. Node deployments dispatch jobs on the maintenance replica (`MARIMOHUB_RUN_MAINTENANCE=true`) and honor the tuning variables below. Cloudflare Workers dispatch from the platform `scheduled()` handler and use the fixed defaults: 5 concurrent runs, 2 per project, 5 jobs per notebook, 1800-second default and 14400-second maximum timeouts, 30-day retention, and a 600-second catch-up window. See [Notebook jobs](./jobs.md).
Expand Down
114 changes: 114 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
description: Connect Claude, Cursor, and other MCP clients to marimohub notebooks.
---

# MCP server

Marimohub exposes notebooks that a user can access through the Model Context
Protocol (MCP). OAuth 2.1 opens a browser consent page. There, the user selects
actions, projects, and token lifetime. The client receives a scoped
[personal access token](./api-tokens.md).

## Enable MCP

MCP is off by default and runs only on the Node server. Set these variables:

```dotenv
MARIMOHUB_MCP=on
MARIMOHUB_APP_BASE_URL=https://hub.example.com
```

`MARIMOHUB_APP_BASE_URL` must include the public origin and any path prefix.
The MCP server URL adds `/mcp` to this value:

```text
https://hub.example.com/mcp
```

OAuth discovery uses the base URL to publish stable, absolute URLs. The API
tokens dialog shows the MCP URL.

## Connect a client

For Claude Code, run:

```bash
claude mcp add --transport http marimohub https://hub.example.com/mcp
```

For Claude.ai, add a custom connector and enter the MCP server URL. For Cursor,
add a remote HTTP MCP server. The client discovers the authorization server,
registers itself, and opens the marimohub consent page.

Before approval, verify the client name and redirect URL. The default grant
permits notebook editing and execution. Use the smallest practical set of
actions and projects. The token lifetime defaults to 7 days and cannot exceed
90 days. Revoke a token from the API tokens dialog. Marimohub does not issue
refresh tokens. Expiry or revocation requires a new authorization.

## Tools

### `list_catalog`

Lists accessible projects and notebooks. Filters by project, notebook status,
tag, or text. Includes active sessions by default.

### `create_notebook`

Creates a local notebook from Python source. Set `launch` to `true` to start an
edit session and return its session details.

### `launch_notebook`

Starts or reuses an edit or app session. The first launch can take about two
minutes. Later calls reuse an eligible session.

### `execute_code`

Runs code in the scratchpad of a live edit session. Open the notebook URL in a
browser before you call this tool. The kernel remains available while a tab is
connected and during marimo's short grace period. If no tab is connected, the
tool returns the URL to open. Pass the project and session ID returned by
`launch_notebook`; the tool automatically uses the first connected kernel.

The scratchpad shares the notebook's live variables. For durable cell changes,
first inspect marimo code mode:

```python
import marimo._code_mode as cm
help(cm)
```

## OAuth and security

Dynamic registration creates public clients that use authorization code and
PKCE S256. Redirect URIs must use HTTPS, loopback HTTP, or a private-use
application scheme. Marimohub supports `cursor:` and reverse-domain,
single-slash application schemes. Authorization codes expire after ten minutes
and can be used once. Authorization requests, token exchanges, and issued tokens
must target the configured MCP URL. Each token also stores the registered client
ID. Other marimohub PATs cannot access `/mcp`.

The `mcp:tools` OAuth scope permits MCP access. The consent grant restricts Hub
actions and projects for each tool call.

Within the configured app base path, MCP reserves these paths:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

- `/mcp`
- `/authorize`
- `/oauth/consent`
- `/token`
- `/register`
- `/revoke`
- `/.well-known/oauth-authorization-server`
- `/.well-known/oauth-protected-resource`
- `/.well-known/oauth-protected-resource/mcp`

The grant does not restrict kernel code or injected credentials. Use a short
token lifetime.

Dynamic registration is anonymous. Marimohub verifies client metadata, enforces
deployment-wide rate limits, and expires registrations after 90 days. Each
successful registration emits an `oauth_client_registered` event without
client-supplied names or URIs. Deployments that require client vetting must add
trusted registration controls before enabling MCP.
Loading
Loading