mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(mcp): oauth schema + minimum admin form
Adds the data model and admin UI surface required by the OAuth-MCP flow.
Phase 2 of the per-user delegation initiative.
Schema:
- migration 049 creates mcp_user_tokens (PK user_id, server_name) and
mcp_oauth_pending (PK state, indexed by created_at)
- eight new columns on mcp_servers: auth_type ('none' / 'static' /
'oauth_user', NOT NULL DEFAULT 'static') plus six oauth_* config
fields and oauth_as_issuer_cached
- post-upgrade UPDATE normalises auth_type to 'none' for streamable-http
rows whose headers are NULL/empty/'{}'; stdio rows are left at the
'static' default (auth_type is HTTP-auth-only)
- _schema.py kept in lockstep with the migration so metadata.create_all
and alembic upgrade produce identical shapes
- mcp_user_tokens / mcp_oauth_pending TypedDicts in _protocol.py for
Phase 3/4 use (no CRUD methods yet)
Storage / API:
- create_mcp_server gains the eight kwargs across protocol + sqlite +
postgresql
- MCP_SERVER_MUTABLE picks up auth_type and the six text oauth_* fields;
oauth_client_secret_ct is intentionally NOT in the whitelist — Phase 3
will own ciphertext writes via a dedicated method
- McpServerInfo + Create/Update Pydantic schemas extended; oauth_client_secret
accepted as plaintext input but discarded (Phase 3 wires encryption)
Admin handlers:
- _parse_auth_type validates against {'none', 'static', 'oauth_user'} and
rejects empty / unknown values; shared between create and update
- when auth_type changes away from 'oauth_user', the oauth_* config
columns are explicitly nulled in the same UPDATE so the row stays
consistent
- _clean_oauth_text caps text fields at 512 chars (URLs at 2048) to bound
admin write surface
- _mask_mcp_secrets now masks oauth_client_secret_ct to '***' regardless
of reveal=true (write-only field)
- audit detail dict redacts oauth_client_secret if present
Frontend:
- new "Multitenant Authorization" fieldset on the MCP-server modal with
three radio buttons (None / Shared / Per-user OAuth 2.1)
- conditional OAuth subform: AS URL, registration mode (preregistered /
dcr; cimd is future), client ID, client secret, scopes, audience
- secret input is autocomplete=off and never round-trips on edit
- audience auto-populates from the MCP server URL on blur
- headers textarea hidden and submitted as {} when auth_type is 'none' or
'oauth_user' so flipping the radio cleans up server-side state
Tests: storage round-trip for the new columns, oauth_pending table smoke,
migration 049 upgrade/downgrade with stdio-vs-http normalisation, four
admin-API tests for auth_type validation and oauth_*-clear-on-flip-away.
Suite passes 5284 (matched pre-Phase-2 baseline 5267 + 17 new).
Stacks on Phase 0; no behavioural change for existing rows.
This commit is contained in:
@@ -319,6 +319,49 @@ class TestCreateMcpServer:
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert "name" in r.json()["error"].lower()
|
assert "name" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
def test_admin_create_oauth_server(self, client):
|
||||||
|
"""Phase 2: admin can POST a server with auth_type=oauth_user
|
||||||
|
and the seven OAuth text fields round-trip via GET."""
|
||||||
|
r = client.post(
|
||||||
|
"/v1/api/admin/mcp-servers",
|
||||||
|
json={
|
||||||
|
"name": "oauth-srv",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"url": "https://mcp.example.com/sse",
|
||||||
|
"auth_type": "oauth_user",
|
||||||
|
"oauth_client_id": "cli_abc",
|
||||||
|
"oauth_client_secret": "should-be-discarded",
|
||||||
|
"oauth_scopes": "openid profile",
|
||||||
|
"oauth_audience": "https://mcp.example.com",
|
||||||
|
"oauth_registration_mode": "preregistered",
|
||||||
|
"oauth_authorization_server_url": "https://auth.example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["auth_type"] == "oauth_user"
|
||||||
|
assert data["oauth_client_id"] == "cli_abc"
|
||||||
|
assert data["oauth_scopes"] == "openid profile"
|
||||||
|
assert data["oauth_audience"] == "https://mcp.example.com"
|
||||||
|
assert data["oauth_registration_mode"] == "preregistered"
|
||||||
|
assert data["oauth_authorization_server_url"] == "https://auth.example.com"
|
||||||
|
# Phase 2 discards the plaintext secret — ciphertext stays NULL,
|
||||||
|
# masked to None in the response.
|
||||||
|
assert data["oauth_client_secret_ct"] is None
|
||||||
|
|
||||||
|
def test_create_invalid_auth_type(self, client):
|
||||||
|
r = client.post(
|
||||||
|
"/v1/api/admin/mcp-servers",
|
||||||
|
json={
|
||||||
|
"name": "bad-auth",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "x",
|
||||||
|
"auth_type": "magic",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "auth_type" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Get single
|
# Get single
|
||||||
@@ -401,6 +444,130 @@ class TestUpdateMcpServer:
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert "transport" in r.json()["error"].lower()
|
assert "transport" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
def test_admin_update_auth_type_static_to_oauth(self, client):
|
||||||
|
"""Phase 2: an existing static row can be flipped to oauth_user
|
||||||
|
with OAuth fields supplied alongside."""
|
||||||
|
created = _create_server(
|
||||||
|
client,
|
||||||
|
name="flip-to-oauth",
|
||||||
|
transport="streamable-http",
|
||||||
|
url="http://mcp.example.com/sse",
|
||||||
|
)
|
||||||
|
sid = created["server_id"]
|
||||||
|
assert created["auth_type"] == "static"
|
||||||
|
|
||||||
|
r = client.put(
|
||||||
|
f"/v1/api/admin/mcp-servers/{sid}",
|
||||||
|
json={
|
||||||
|
"auth_type": "oauth_user",
|
||||||
|
"oauth_client_id": "cli_xyz",
|
||||||
|
"oauth_audience": "https://mcp.example.com",
|
||||||
|
"oauth_registration_mode": "dcr",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["auth_type"] == "oauth_user"
|
||||||
|
assert data["oauth_client_id"] == "cli_xyz"
|
||||||
|
assert data["oauth_audience"] == "https://mcp.example.com"
|
||||||
|
assert data["oauth_registration_mode"] == "dcr"
|
||||||
|
|
||||||
|
def test_update_invalid_auth_type(self, client):
|
||||||
|
created = _create_server(client, name="bad-auth-update")
|
||||||
|
sid = created["server_id"]
|
||||||
|
r = client.put(
|
||||||
|
f"/v1/api/admin/mcp-servers/{sid}",
|
||||||
|
json={"auth_type": "wat"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "auth_type" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
def test_update_empty_auth_type_rejected(self, client):
|
||||||
|
"""Empty-string auth_type is rejected (no silent coercion to 'static')."""
|
||||||
|
created = _create_server(client, name="empty-auth-update")
|
||||||
|
sid = created["server_id"]
|
||||||
|
r = client.put(
|
||||||
|
f"/v1/api/admin/mcp-servers/{sid}",
|
||||||
|
json={"auth_type": ""},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "auth_type" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
def test_create_empty_auth_type_rejected(self, client):
|
||||||
|
"""Empty-string auth_type on create is rejected too."""
|
||||||
|
r = client.post(
|
||||||
|
"/v1/api/admin/mcp-servers",
|
||||||
|
json={
|
||||||
|
"name": "empty-auth-create",
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "x",
|
||||||
|
"auth_type": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "auth_type" in r.json()["error"].lower()
|
||||||
|
|
||||||
|
def test_update_auth_type_oauth_to_static_clears_oauth_fields(self, client):
|
||||||
|
"""Flipping auth_type away from oauth_user clears the oauth_* text
|
||||||
|
columns so a stale client_id / audience can't leak back."""
|
||||||
|
# Seed an oauth_user row with all fields populated.
|
||||||
|
r = client.post(
|
||||||
|
"/v1/api/admin/mcp-servers",
|
||||||
|
json={
|
||||||
|
"name": "flip-away",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"url": "https://mcp.example.com/sse",
|
||||||
|
"auth_type": "oauth_user",
|
||||||
|
"oauth_client_id": "cli_seed",
|
||||||
|
"oauth_scopes": "openid",
|
||||||
|
"oauth_audience": "https://mcp.example.com",
|
||||||
|
"oauth_registration_mode": "preregistered",
|
||||||
|
"oauth_authorization_server_url": "https://auth.example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
sid = r.json()["server_id"]
|
||||||
|
|
||||||
|
# Flip to static — server should clear all oauth_* text fields.
|
||||||
|
r = client.put(
|
||||||
|
f"/v1/api/admin/mcp-servers/{sid}",
|
||||||
|
json={"auth_type": "static"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["auth_type"] == "static"
|
||||||
|
assert data["oauth_client_id"] is None
|
||||||
|
assert data["oauth_scopes"] is None
|
||||||
|
assert data["oauth_audience"] is None
|
||||||
|
assert data["oauth_registration_mode"] is None
|
||||||
|
assert data["oauth_authorization_server_url"] is None
|
||||||
|
|
||||||
|
def test_update_auth_type_oauth_to_none_clears_oauth_fields(self, client):
|
||||||
|
"""Same clear behavior when flipping to 'none'."""
|
||||||
|
r = client.post(
|
||||||
|
"/v1/api/admin/mcp-servers",
|
||||||
|
json={
|
||||||
|
"name": "flip-to-none",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"url": "https://mcp.example.com/sse",
|
||||||
|
"auth_type": "oauth_user",
|
||||||
|
"oauth_client_id": "cli_seed2",
|
||||||
|
"oauth_audience": "https://mcp.example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
sid = r.json()["server_id"]
|
||||||
|
|
||||||
|
r = client.put(
|
||||||
|
f"/v1/api/admin/mcp-servers/{sid}",
|
||||||
|
json={"auth_type": "none"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert data["auth_type"] == "none"
|
||||||
|
assert data["oauth_client_id"] is None
|
||||||
|
assert data["oauth_audience"] is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Delete
|
# Delete
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Smoke tests for the new OAuth-MCP storage tables.
|
||||||
|
|
||||||
|
Phase 2 only adds the schema — token CRUD lands in Phase 3 and pending-
|
||||||
|
state CRUD in Phase 4. These tests verify the tables exist after
|
||||||
|
``init_storage`` and accept the documented row shape via raw SQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from turnstone.core.storage._schema import mcp_oauth_pending, mcp_user_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpUserTokensTable:
|
||||||
|
def test_table_exists_and_accepts_row(self, backend) -> None:
|
||||||
|
with backend._engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(mcp_user_tokens),
|
||||||
|
{
|
||||||
|
"user_id": "u1",
|
||||||
|
"server_name": "srv-a",
|
||||||
|
"access_token_ct": b"\x00ciphertext-a",
|
||||||
|
"refresh_token_ct": b"\x00ciphertext-r",
|
||||||
|
"expires_at": "2026-05-04T12:00:00",
|
||||||
|
"scopes": "openid profile",
|
||||||
|
"as_issuer": "https://auth.example.com",
|
||||||
|
"audience": "https://mcp.example.com",
|
||||||
|
"created": "2026-05-04T11:00:00",
|
||||||
|
"last_refreshed": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(mcp_user_tokens).where(
|
||||||
|
(mcp_user_tokens.c.user_id == "u1") & (mcp_user_tokens.c.server_name == "srv-a")
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert row.access_token_ct == b"\x00ciphertext-a"
|
||||||
|
assert row.refresh_token_ct == b"\x00ciphertext-r"
|
||||||
|
assert row.scopes == "openid profile"
|
||||||
|
assert row.audience == "https://mcp.example.com"
|
||||||
|
|
||||||
|
def test_composite_pk_distinguishes_user_server(self, backend) -> None:
|
||||||
|
"""Same user, different server => two rows; same (user, server) => conflict."""
|
||||||
|
with backend._engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(mcp_user_tokens),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"user_id": "u1",
|
||||||
|
"server_name": "srv-a",
|
||||||
|
"access_token_ct": b"a",
|
||||||
|
"refresh_token_ct": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"scopes": None,
|
||||||
|
"as_issuer": "https://auth.example.com",
|
||||||
|
"audience": "https://a.example.com",
|
||||||
|
"created": "2026-05-04T11:00:00",
|
||||||
|
"last_refreshed": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_id": "u1",
|
||||||
|
"server_name": "srv-b",
|
||||||
|
"access_token_ct": b"b",
|
||||||
|
"refresh_token_ct": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"scopes": None,
|
||||||
|
"as_issuer": "https://auth.example.com",
|
||||||
|
"audience": "https://b.example.com",
|
||||||
|
"created": "2026-05-04T11:00:00",
|
||||||
|
"last_refreshed": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
count = conn.execute(sa.select(sa.func.count()).select_from(mcp_user_tokens)).scalar()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpOauthPendingTable:
|
||||||
|
def test_table_exists_and_accepts_row(self, backend) -> None:
|
||||||
|
with backend._engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(mcp_oauth_pending),
|
||||||
|
{
|
||||||
|
"state": "rand-state-xyz",
|
||||||
|
"user_id": "u1",
|
||||||
|
"server_name": "srv-a",
|
||||||
|
"code_verifier": "verifier-blob",
|
||||||
|
"return_url": "/admin/mcp-servers",
|
||||||
|
"created_at": "2026-05-04T11:00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(mcp_oauth_pending).where(mcp_oauth_pending.c.state == "rand-state-xyz")
|
||||||
|
).one()
|
||||||
|
assert row.user_id == "u1"
|
||||||
|
assert row.server_name == "srv-a"
|
||||||
|
assert row.return_url == "/admin/mcp-servers"
|
||||||
|
|
||||||
|
def test_state_pk_unique(self, backend) -> None:
|
||||||
|
"""A second insert with the same state value raises IntegrityError."""
|
||||||
|
with backend._engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(mcp_oauth_pending),
|
||||||
|
{
|
||||||
|
"state": "dup-state",
|
||||||
|
"user_id": "u1",
|
||||||
|
"server_name": "srv-a",
|
||||||
|
"code_verifier": "v",
|
||||||
|
"return_url": "/x",
|
||||||
|
"created_at": "2026-05-04T11:00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError), backend._engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.insert(mcp_oauth_pending),
|
||||||
|
{
|
||||||
|
"state": "dup-state",
|
||||||
|
"user_id": "u2",
|
||||||
|
"server_name": "srv-b",
|
||||||
|
"code_verifier": "v",
|
||||||
|
"return_url": "/y",
|
||||||
|
"created_at": "2026-05-04T11:01:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for alembic migration 049 (OAuth-MCP schema).
|
||||||
|
|
||||||
|
Drives ``command.upgrade`` from a programmatic Alembic config against
|
||||||
|
an isolated SQLite database per test, then asserts:
|
||||||
|
|
||||||
|
* the two new tables (``mcp_user_tokens``, ``mcp_oauth_pending``) exist,
|
||||||
|
* the eight new ``mcp_servers`` columns exist,
|
||||||
|
* the post-upgrade ``UPDATE mcp_servers`` normalization rewrites rows
|
||||||
|
with empty / missing headers to ``auth_type='none'`` while leaving
|
||||||
|
rows with non-empty headers at ``auth_type='static'``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
_MIGRATIONS_DIR = str(
|
||||||
|
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_cfg(db_path: Path) -> Config:
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option("script_location", _MIGRATIONS_DIR)
|
||||||
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration049:
|
||||||
|
def test_creates_new_tables_and_columns(self, tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "049.db"
|
||||||
|
cfg = _alembic_cfg(db_path)
|
||||||
|
|
||||||
|
# Walk forward through 048 first, then explicitly to 049 so we
|
||||||
|
# exercise the *upgrade* function (not just the schema's `head`).
|
||||||
|
command.upgrade(cfg, "048")
|
||||||
|
command.upgrade(cfg, "049")
|
||||||
|
|
||||||
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
assert "mcp_user_tokens" in tables
|
||||||
|
assert "mcp_oauth_pending" in tables
|
||||||
|
|
||||||
|
mcp_cols = {c["name"] for c in inspector.get_columns("mcp_servers")}
|
||||||
|
new_cols = {
|
||||||
|
"auth_type",
|
||||||
|
"oauth_client_id",
|
||||||
|
"oauth_client_secret_ct",
|
||||||
|
"oauth_scopes",
|
||||||
|
"oauth_audience",
|
||||||
|
"oauth_registration_mode",
|
||||||
|
"oauth_authorization_server_url",
|
||||||
|
"oauth_as_issuer_cached",
|
||||||
|
}
|
||||||
|
assert new_cols.issubset(mcp_cols), new_cols - mcp_cols
|
||||||
|
|
||||||
|
# Index check on mcp_oauth_pending.
|
||||||
|
indexes = {ix["name"] for ix in inspector.get_indexes("mcp_oauth_pending")}
|
||||||
|
assert "idx_mcp_pending_created" in indexes
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_normalizes_empty_headers_to_none(self, tmp_path: Path) -> None:
|
||||||
|
"""Streamable-http rows with NULL / '' / '{}' headers become
|
||||||
|
auth_type='none'; rows with non-empty headers stay 'static'.
|
||||||
|
Stdio rows always stay 'static' regardless of headers — the
|
||||||
|
column value is opaque when there is no HTTP transport."""
|
||||||
|
db_path = tmp_path / "049-norm.db"
|
||||||
|
cfg = _alembic_cfg(db_path)
|
||||||
|
|
||||||
|
# Apply everything up to 048, seed rows, then apply 049.
|
||||||
|
command.upgrade(cfg, "048")
|
||||||
|
|
||||||
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO mcp_servers (
|
||||||
|
server_id, name, transport, command, args, url,
|
||||||
|
headers, env, auto_approve, enabled, created_by,
|
||||||
|
registry_name, registry_version, registry_meta,
|
||||||
|
created, updated
|
||||||
|
) VALUES (
|
||||||
|
:sid, :name, :transport, '', '[]',
|
||||||
|
'https://x', :headers, '{}', 0, 1, '', NULL, '',
|
||||||
|
'{}', '2026-05-04T11:00:00', '2026-05-04T11:00:00'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sid": "s-empty-str",
|
||||||
|
"name": "empty-str",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"headers": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sid": "s-empty-obj",
|
||||||
|
"name": "empty-obj",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"headers": "{}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sid": "s-with-headers",
|
||||||
|
"name": "with-headers",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"headers": '{"Authorization":"Bearer x"}',
|
||||||
|
},
|
||||||
|
# Stdio rows must keep the 'static' default, even
|
||||||
|
# though their headers are empty — auth_type is
|
||||||
|
# opaque for stdio.
|
||||||
|
{
|
||||||
|
"sid": "s-stdio-empty",
|
||||||
|
"name": "stdio-empty",
|
||||||
|
"transport": "stdio",
|
||||||
|
"headers": "{}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sid": "s-stdio-null",
|
||||||
|
"name": "stdio-null",
|
||||||
|
"transport": "stdio",
|
||||||
|
"headers": "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
command.upgrade(cfg, "049")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = dict(conn.execute(sa.text("SELECT name, auth_type FROM mcp_servers")).all())
|
||||||
|
assert rows["empty-str"] == "none"
|
||||||
|
assert rows["empty-obj"] == "none"
|
||||||
|
assert rows["with-headers"] == "static"
|
||||||
|
# Stdio rows must remain at the 'static' column default even
|
||||||
|
# when headers are empty — the migration only touches HTTP
|
||||||
|
# rows where auth_type is semantically meaningful.
|
||||||
|
assert rows["stdio-empty"] == "static"
|
||||||
|
assert rows["stdio-null"] == "static"
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_full_chain_to_head(self, tmp_path: Path) -> None:
|
||||||
|
"""Sanity: running ``upgrade head`` on a fresh DB yields the
|
||||||
|
same end-state column set as ``_schema.metadata``."""
|
||||||
|
db_path = tmp_path / "049-head.db"
|
||||||
|
cfg = _alembic_cfg(db_path)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
from turnstone.core.storage._schema import mcp_servers
|
||||||
|
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
actual = {c["name"] for c in inspector.get_columns("mcp_servers")}
|
||||||
|
expected = {c.name for c in mcp_servers.columns}
|
||||||
|
assert expected.issubset(actual), expected - actual
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
@@ -939,6 +939,64 @@ class TestTouchWorkstream:
|
|||||||
backend.touch_workstream("nonexistent") # must not raise
|
backend.touch_workstream("nonexistent") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
# -- MCP OAuth columns ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpServerOauthColumns:
|
||||||
|
def test_mcp_servers_oauth_columns_round_trip(self, backend: Any) -> None:
|
||||||
|
"""An oauth_user row round-trips through create -> get with all
|
||||||
|
seven OAuth text columns intact."""
|
||||||
|
sid = "oauth-srv-1"
|
||||||
|
backend.create_mcp_server(
|
||||||
|
server_id=sid,
|
||||||
|
name="oauth-srv",
|
||||||
|
transport="streamable-http",
|
||||||
|
url="https://mcp.example.com/sse",
|
||||||
|
auth_type="oauth_user",
|
||||||
|
oauth_client_id="cli_abc123",
|
||||||
|
oauth_scopes="openid profile",
|
||||||
|
oauth_audience="https://mcp.example.com",
|
||||||
|
oauth_registration_mode="preregistered",
|
||||||
|
oauth_authorization_server_url="https://auth.example.com",
|
||||||
|
oauth_as_issuer_cached="https://auth.example.com",
|
||||||
|
)
|
||||||
|
s = backend.get_mcp_server(sid)
|
||||||
|
assert s is not None
|
||||||
|
assert s["auth_type"] == "oauth_user"
|
||||||
|
assert s["oauth_client_id"] == "cli_abc123"
|
||||||
|
assert s["oauth_scopes"] == "openid profile"
|
||||||
|
assert s["oauth_audience"] == "https://mcp.example.com"
|
||||||
|
assert s["oauth_registration_mode"] == "preregistered"
|
||||||
|
assert s["oauth_authorization_server_url"] == "https://auth.example.com"
|
||||||
|
assert s["oauth_as_issuer_cached"] == "https://auth.example.com"
|
||||||
|
# Phase 2 leaves the ciphertext slot NULL even when other oauth
|
||||||
|
# fields are populated; Phase 3 wires the encryption write path.
|
||||||
|
assert s["oauth_client_secret_ct"] is None
|
||||||
|
|
||||||
|
def test_update_auth_type_static_to_oauth(self, backend: Any) -> None:
|
||||||
|
sid = "oauth-srv-2"
|
||||||
|
backend.create_mcp_server(
|
||||||
|
server_id=sid,
|
||||||
|
name="static-then-oauth",
|
||||||
|
transport="streamable-http",
|
||||||
|
url="https://mcp.example.com/sse",
|
||||||
|
)
|
||||||
|
assert backend.get_mcp_server(sid)["auth_type"] == "static"
|
||||||
|
|
||||||
|
ok = backend.update_mcp_server(
|
||||||
|
sid,
|
||||||
|
auth_type="oauth_user",
|
||||||
|
oauth_client_id="cli_after",
|
||||||
|
oauth_audience="https://mcp.example.com",
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
s = backend.get_mcp_server(sid)
|
||||||
|
assert s is not None
|
||||||
|
assert s["auth_type"] == "oauth_user"
|
||||||
|
assert s["oauth_client_id"] == "cli_after"
|
||||||
|
assert s["oauth_audience"] == "https://mcp.example.com"
|
||||||
|
|
||||||
|
|
||||||
# -- Lifecycle -----------------------------------------------------------------
|
# -- Lifecycle -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -674,6 +674,16 @@ class McpServerInfo(BaseModel):
|
|||||||
registry_name: str | None = None
|
registry_name: str | None = None
|
||||||
registry_version: str = ""
|
registry_version: str = ""
|
||||||
registry_meta: str = "{}"
|
registry_meta: str = "{}"
|
||||||
|
auth_type: str = "static"
|
||||||
|
oauth_client_id: str | None = None
|
||||||
|
oauth_scopes: str | None = None
|
||||||
|
oauth_audience: str | None = None
|
||||||
|
oauth_registration_mode: str | None = None
|
||||||
|
oauth_authorization_server_url: str | None = None
|
||||||
|
oauth_as_issuer_cached: str | None = None
|
||||||
|
# Fernet ciphertext; never decrypted on the read path. Responses
|
||||||
|
# carry the masked ``"***"`` sentinel via ``_mask_mcp_secrets``.
|
||||||
|
oauth_client_secret_ct: str | None = None
|
||||||
created: str
|
created: str
|
||||||
updated: str
|
updated: str
|
||||||
|
|
||||||
@@ -704,6 +714,16 @@ class CreateMcpServerRequest(BaseModel):
|
|||||||
env: dict[str, str] = Field(default_factory=dict)
|
env: dict[str, str] = Field(default_factory=dict)
|
||||||
auto_approve: bool = False
|
auto_approve: bool = False
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
# OAuth-MCP: one of 'none' | 'static' | 'oauth_user'.
|
||||||
|
# ``oauth_client_secret`` is plaintext input; never persisted,
|
||||||
|
# redacted in audit log.
|
||||||
|
auth_type: str = "static"
|
||||||
|
oauth_client_id: str | None = None
|
||||||
|
oauth_client_secret: str | None = None
|
||||||
|
oauth_scopes: str | None = None
|
||||||
|
oauth_audience: str | None = None
|
||||||
|
oauth_registration_mode: str | None = None
|
||||||
|
oauth_authorization_server_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateMcpServerRequest(BaseModel):
|
class UpdateMcpServerRequest(BaseModel):
|
||||||
@@ -716,6 +736,13 @@ class UpdateMcpServerRequest(BaseModel):
|
|||||||
env: dict[str, str] | None = None
|
env: dict[str, str] | None = None
|
||||||
auto_approve: bool | None = None
|
auto_approve: bool | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
|
auth_type: str | None = None
|
||||||
|
oauth_client_id: str | None = None
|
||||||
|
oauth_client_secret: str | None = None
|
||||||
|
oauth_scopes: str | None = None
|
||||||
|
oauth_audience: str | None = None
|
||||||
|
oauth_registration_mode: str | None = None
|
||||||
|
oauth_authorization_server_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListMcpServersResponse(BaseModel):
|
class ListMcpServersResponse(BaseModel):
|
||||||
|
|||||||
+105
-4
@@ -7965,6 +7965,42 @@ async def admin_registry_install(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
_MCP_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
_MCP_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||||
_MCP_MAX_SERVERS = 200 # fallback; prefer cluster.mcp_max_servers from storage
|
_MCP_MAX_SERVERS = 200 # fallback; prefer cluster.mcp_max_servers from storage
|
||||||
|
_MCP_AUTH_TYPES = frozenset({"none", "static", "oauth_user"})
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_oauth_text(value: Any, *, max_length: int = 512) -> str | None:
|
||||||
|
"""Normalize an admin form OAuth text field — empty string -> None.
|
||||||
|
|
||||||
|
Caps the input to ``max_length`` characters to bound DB row size on
|
||||||
|
the admin.mcp write path. Pass a larger ``max_length`` (e.g. 2048)
|
||||||
|
for URL fields where the default would otherwise truncate valid
|
||||||
|
long URLs.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_auth_type(body: dict[str, Any]) -> tuple[str | None, JSONResponse | None]:
|
||||||
|
"""Validate ``auth_type`` from a request body.
|
||||||
|
|
||||||
|
Returns ``(value, None)`` for a valid value, ``(None, error)`` for
|
||||||
|
a present-but-invalid value (caller returns ``error``), or
|
||||||
|
``(None, None)`` when ``auth_type`` is absent (caller skips the
|
||||||
|
update / falls back to a default).
|
||||||
|
"""
|
||||||
|
if "auth_type" not in body:
|
||||||
|
return None, None
|
||||||
|
auth_type = str(body["auth_type"]).strip()
|
||||||
|
if auth_type not in _MCP_AUTH_TYPES:
|
||||||
|
return None, JSONResponse(
|
||||||
|
{"error": "auth_type must be 'none', 'static', or 'oauth_user'"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
return auth_type, None
|
||||||
|
|
||||||
|
|
||||||
def _get_mcp_max_servers(request: Request) -> int:
|
def _get_mcp_max_servers(request: Request) -> int:
|
||||||
@@ -7976,10 +8012,20 @@ def _get_mcp_max_servers(request: Request) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _mask_mcp_secrets(server: dict[str, Any], reveal: bool = False) -> dict[str, Any]:
|
def _mask_mcp_secrets(server: dict[str, Any], reveal: bool = False) -> dict[str, Any]:
|
||||||
"""Replace env/headers values with '***' unless reveal is True."""
|
"""Mask secret fields on an MCP server response dict.
|
||||||
if reveal:
|
|
||||||
return server
|
``env`` and ``headers`` are masked only when ``reveal`` is False.
|
||||||
|
``oauth_client_secret_ct`` is always masked regardless of ``reveal``
|
||||||
|
— it's a write-only field (the admin form accepts plaintext but
|
||||||
|
the response just signals presence-or-absence as ``"***"`` /
|
||||||
|
``None``).
|
||||||
|
"""
|
||||||
s = dict(server)
|
s = dict(server)
|
||||||
|
# OAuth client secret ciphertext is write-only at every read path.
|
||||||
|
raw_secret = s.get("oauth_client_secret_ct")
|
||||||
|
s["oauth_client_secret_ct"] = "***" if raw_secret is not None else None
|
||||||
|
if reveal:
|
||||||
|
return s
|
||||||
if s.get("env") and s["env"] != "{}":
|
if s.get("env") and s["env"] != "{}":
|
||||||
try:
|
try:
|
||||||
env_dict = json.loads(s["env"]) if isinstance(s["env"], str) else s["env"]
|
env_dict = json.loads(s["env"]) if isinstance(s["env"], str) else s["env"]
|
||||||
@@ -8100,6 +8146,7 @@ async def admin_list_mcp_servers(request: Request) -> JSONResponse:
|
|||||||
"auto_approve": False,
|
"auto_approve": False,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"created_by": "",
|
"created_by": "",
|
||||||
|
"auth_type": "static",
|
||||||
"created": "",
|
"created": "",
|
||||||
"updated": "",
|
"updated": "",
|
||||||
"source": "config",
|
"source": "config",
|
||||||
@@ -8155,6 +8202,12 @@ async def admin_create_mcp_server(request: Request) -> JSONResponse:
|
|||||||
{"error": "url is required for streamable-http transport"}, status_code=400
|
{"error": "url is required for streamable-http transport"}, status_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auth_type_value, err_resp = _parse_auth_type(body)
|
||||||
|
if err_resp is not None:
|
||||||
|
return err_resp
|
||||||
|
# Helper returns None when key is absent — fall back to the default.
|
||||||
|
auth_type = auth_type_value if auth_type_value is not None else "static"
|
||||||
|
|
||||||
# Check max servers
|
# Check max servers
|
||||||
existing = storage.list_mcp_servers()
|
existing = storage.list_mcp_servers()
|
||||||
max_servers = _get_mcp_max_servers(request)
|
max_servers = _get_mcp_max_servers(request)
|
||||||
@@ -8190,15 +8243,26 @@ async def admin_create_mcp_server(request: Request) -> JSONResponse:
|
|||||||
auto_approve=bool(body.get("auto_approve", False)),
|
auto_approve=bool(body.get("auto_approve", False)),
|
||||||
enabled=bool(body.get("enabled", True)),
|
enabled=bool(body.get("enabled", True)),
|
||||||
created_by=audit_uid,
|
created_by=audit_uid,
|
||||||
|
auth_type=auth_type,
|
||||||
|
oauth_client_id=_clean_oauth_text(body.get("oauth_client_id")),
|
||||||
|
oauth_scopes=_clean_oauth_text(body.get("oauth_scopes")),
|
||||||
|
oauth_audience=_clean_oauth_text(body.get("oauth_audience"), max_length=2048),
|
||||||
|
oauth_registration_mode=_clean_oauth_text(body.get("oauth_registration_mode")),
|
||||||
|
oauth_authorization_server_url=_clean_oauth_text(
|
||||||
|
body.get("oauth_authorization_server_url"), max_length=2048
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
audit_detail: dict[str, Any] = {"name": name, "auth_type": auth_type}
|
||||||
|
if "oauth_client_secret" in body:
|
||||||
|
audit_detail["oauth_client_secret"] = "(redacted)"
|
||||||
record_audit(
|
record_audit(
|
||||||
storage,
|
storage,
|
||||||
audit_uid,
|
audit_uid,
|
||||||
"mcp_server.create",
|
"mcp_server.create",
|
||||||
"mcp_server",
|
"mcp_server",
|
||||||
server_id,
|
server_id,
|
||||||
{"name": name},
|
audit_detail,
|
||||||
ip,
|
ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -8302,6 +8366,41 @@ async def admin_update_mcp_server(request: Request) -> JSONResponse:
|
|||||||
updates["auto_approve"] = bool(body["auto_approve"])
|
updates["auto_approve"] = bool(body["auto_approve"])
|
||||||
if "enabled" in body:
|
if "enabled" in body:
|
||||||
updates["enabled"] = bool(body["enabled"])
|
updates["enabled"] = bool(body["enabled"])
|
||||||
|
auth_type_value, err_resp = _parse_auth_type(body)
|
||||||
|
if err_resp is not None:
|
||||||
|
return err_resp
|
||||||
|
if auth_type_value is not None:
|
||||||
|
updates["auth_type"] = auth_type_value
|
||||||
|
for _oauth_key in (
|
||||||
|
"oauth_client_id",
|
||||||
|
"oauth_scopes",
|
||||||
|
"oauth_registration_mode",
|
||||||
|
):
|
||||||
|
if _oauth_key in body:
|
||||||
|
updates[_oauth_key] = _clean_oauth_text(body[_oauth_key])
|
||||||
|
for _oauth_url_key in (
|
||||||
|
"oauth_audience",
|
||||||
|
"oauth_authorization_server_url",
|
||||||
|
):
|
||||||
|
if _oauth_url_key in body:
|
||||||
|
updates[_oauth_url_key] = _clean_oauth_text(body[_oauth_url_key], max_length=2048)
|
||||||
|
|
||||||
|
# When auth_type is changed away from oauth_user, clear the OAuth
|
||||||
|
# columns so a stale client_id / audience can't leak back if the
|
||||||
|
# row is later flipped to a different oauth_user provider.
|
||||||
|
# ``oauth_client_secret_ct`` is owned by a dedicated write path
|
||||||
|
# (not the generic update); its clear-on-change lives there.
|
||||||
|
if updates.get("auth_type") and updates["auth_type"] != "oauth_user":
|
||||||
|
updates.update(
|
||||||
|
{
|
||||||
|
"oauth_client_id": None,
|
||||||
|
"oauth_scopes": None,
|
||||||
|
"oauth_audience": None,
|
||||||
|
"oauth_registration_mode": None,
|
||||||
|
"oauth_authorization_server_url": None,
|
||||||
|
"oauth_as_issuer_cached": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
storage.update_mcp_server(server_id, **updates)
|
storage.update_mcp_server(server_id, **updates)
|
||||||
@@ -8311,6 +8410,8 @@ async def admin_update_mcp_server(request: Request) -> JSONResponse:
|
|||||||
for _secret_key in ("env", "headers"):
|
for _secret_key in ("env", "headers"):
|
||||||
if _secret_key in audit_detail:
|
if _secret_key in audit_detail:
|
||||||
audit_detail[_secret_key] = "(updated)"
|
audit_detail[_secret_key] = "(updated)"
|
||||||
|
if "oauth_client_secret" in body:
|
||||||
|
audit_detail["oauth_client_secret"] = "(redacted)"
|
||||||
record_audit(
|
record_audit(
|
||||||
storage,
|
storage,
|
||||||
audit_uid,
|
audit_uid,
|
||||||
|
|||||||
@@ -3465,6 +3465,46 @@ function toggleMcpTransport() {
|
|||||||
v === "stdio" ? "" : "none";
|
v === "stdio" ? "" : "none";
|
||||||
document.getElementById("mcp-http-fields").style.display =
|
document.getElementById("mcp-http-fields").style.display =
|
||||||
v === "streamable-http" ? "" : "none";
|
v === "streamable-http" ? "" : "none";
|
||||||
|
// Re-evaluate auth-field visibility because the headers row lives
|
||||||
|
// inside mcp-http-fields and gets toggled there.
|
||||||
|
toggleMcpAuthFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _selectedMcpAuthType() {
|
||||||
|
var radios = document.getElementsByName("mcp-auth-type");
|
||||||
|
for (var i = 0; i < radios.length; i++) {
|
||||||
|
if (radios[i].checked) return radios[i].value;
|
||||||
|
}
|
||||||
|
return "static";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMcpAuthFields() {
|
||||||
|
var authType = _selectedMcpAuthType();
|
||||||
|
var oauthDiv = document.getElementById("mcp-oauth-fields");
|
||||||
|
if (oauthDiv) {
|
||||||
|
oauthDiv.style.display = authType === "oauth_user" ? "" : "none";
|
||||||
|
}
|
||||||
|
// The "Headers" textarea (inside mcp-http-fields) is only meaningful
|
||||||
|
// for static auth; hide it for 'none' / 'oauth_user' so operators
|
||||||
|
// don't accidentally configure stale credentials.
|
||||||
|
var headersInput = document.getElementById("mcp-headers");
|
||||||
|
if (headersInput) {
|
||||||
|
var headersLabel = document.querySelector('label[for="mcp-headers"]');
|
||||||
|
var show = authType === "static";
|
||||||
|
headersInput.style.display = show ? "" : "none";
|
||||||
|
if (headersLabel) headersLabel.style.display = show ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireMcpAudienceAutofill() {
|
||||||
|
// Idempotent — only attach the listener once per page lifetime.
|
||||||
|
var urlInput = document.getElementById("mcp-url");
|
||||||
|
if (!urlInput || urlInput.dataset.audAutofill === "1") return;
|
||||||
|
urlInput.dataset.audAutofill = "1";
|
||||||
|
urlInput.addEventListener("blur", function () {
|
||||||
|
var aud = document.getElementById("mcp-oauth-audience");
|
||||||
|
if (aud && !aud.value.trim()) aud.value = urlInput.value.trim();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCreateMcpModal() {
|
function showCreateMcpModal() {
|
||||||
@@ -3483,8 +3523,20 @@ function showCreateMcpModal() {
|
|||||||
document.getElementById("mcp-headers").value = "";
|
document.getElementById("mcp-headers").value = "";
|
||||||
document.getElementById("mcp-auto-approve").checked = false;
|
document.getElementById("mcp-auto-approve").checked = false;
|
||||||
document.getElementById("mcp-enabled").checked = true;
|
document.getElementById("mcp-enabled").checked = true;
|
||||||
|
// Reset auth radios + OAuth subfields to the 'static' default.
|
||||||
|
document.getElementById("mcp-auth-static").checked = true;
|
||||||
|
document.getElementById("mcp-auth-none").checked = false;
|
||||||
|
document.getElementById("mcp-auth-oauth").checked = false;
|
||||||
|
document.getElementById("mcp-oauth-as-url").value = "";
|
||||||
|
document.getElementById("mcp-oauth-registration").value = "preregistered";
|
||||||
|
document.getElementById("mcp-oauth-client-id").value = "";
|
||||||
|
document.getElementById("mcp-oauth-client-secret").value = "";
|
||||||
|
document.getElementById("mcp-oauth-scopes").value = "";
|
||||||
|
document.getElementById("mcp-oauth-audience").value = "";
|
||||||
document.getElementById("mcp-create-error").style.display = "none";
|
document.getElementById("mcp-create-error").style.display = "none";
|
||||||
toggleMcpTransport();
|
toggleMcpTransport();
|
||||||
|
toggleMcpAuthFields();
|
||||||
|
_wireMcpAudienceAutofill();
|
||||||
document.getElementById("mcp-name").focus();
|
document.getElementById("mcp-name").focus();
|
||||||
_mcpCreateTrap = _installTrap("mcp-create-overlay", "mcp-create-box");
|
_mcpCreateTrap = _installTrap("mcp-create-overlay", "mcp-create-box");
|
||||||
}
|
}
|
||||||
@@ -3535,7 +3587,25 @@ function showEditMcpModal(serverId) {
|
|||||||
document.getElementById("mcp-auto-approve").checked =
|
document.getElementById("mcp-auto-approve").checked =
|
||||||
s.auto_approve || false;
|
s.auto_approve || false;
|
||||||
document.getElementById("mcp-enabled").checked = s.enabled !== false;
|
document.getElementById("mcp-enabled").checked = s.enabled !== false;
|
||||||
|
var authType = s.auth_type || "static";
|
||||||
|
document.getElementById("mcp-auth-none").checked = authType === "none";
|
||||||
|
document.getElementById("mcp-auth-static").checked =
|
||||||
|
authType === "static";
|
||||||
|
document.getElementById("mcp-auth-oauth").checked =
|
||||||
|
authType === "oauth_user";
|
||||||
|
document.getElementById("mcp-oauth-as-url").value =
|
||||||
|
s.oauth_authorization_server_url || "";
|
||||||
|
document.getElementById("mcp-oauth-registration").value =
|
||||||
|
s.oauth_registration_mode || "preregistered";
|
||||||
|
document.getElementById("mcp-oauth-client-id").value =
|
||||||
|
s.oauth_client_id || "";
|
||||||
|
// Secret field always blank — write-only, never read back.
|
||||||
|
document.getElementById("mcp-oauth-client-secret").value = "";
|
||||||
|
document.getElementById("mcp-oauth-scopes").value = s.oauth_scopes || "";
|
||||||
|
document.getElementById("mcp-oauth-audience").value =
|
||||||
|
s.oauth_audience || "";
|
||||||
toggleMcpTransport();
|
toggleMcpTransport();
|
||||||
|
toggleMcpAuthFields();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
showToast("Failed to load server details");
|
showToast("Failed to load server details");
|
||||||
@@ -3557,11 +3627,13 @@ function _parseMcpForm() {
|
|||||||
return { error: "Name must match [a-zA-Z0-9._-]+" };
|
return { error: "Name must match [a-zA-Z0-9._-]+" };
|
||||||
if (name.indexOf("__") >= 0) return { error: "Name must not contain '__'" };
|
if (name.indexOf("__") >= 0) return { error: "Name must not contain '__'" };
|
||||||
|
|
||||||
|
var authType = _selectedMcpAuthType();
|
||||||
var payload = {
|
var payload = {
|
||||||
name: name,
|
name: name,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
auto_approve: document.getElementById("mcp-auto-approve").checked,
|
auto_approve: document.getElementById("mcp-auto-approve").checked,
|
||||||
enabled: document.getElementById("mcp-enabled").checked,
|
enabled: document.getElementById("mcp-enabled").checked,
|
||||||
|
auth_type: authType,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (transport === "stdio") {
|
if (transport === "stdio") {
|
||||||
@@ -3587,19 +3659,46 @@ function _parseMcpForm() {
|
|||||||
payload.env = envObj;
|
payload.env = envObj;
|
||||||
} else {
|
} else {
|
||||||
payload.url = document.getElementById("mcp-url").value.trim();
|
payload.url = document.getElementById("mcp-url").value.trim();
|
||||||
var hdrText = document.getElementById("mcp-headers").value.trim();
|
if (authType === "static") {
|
||||||
var hdrObj = {};
|
var hdrText = document.getElementById("mcp-headers").value.trim();
|
||||||
if (hdrText) {
|
var hdrObj = {};
|
||||||
hdrText.split("\n").forEach(function (line) {
|
if (hdrText) {
|
||||||
var colon = line.indexOf(":");
|
hdrText.split("\n").forEach(function (line) {
|
||||||
if (colon > 0)
|
var colon = line.indexOf(":");
|
||||||
hdrObj[line.substring(0, colon).trim()] = line
|
if (colon > 0)
|
||||||
.substring(colon + 1)
|
hdrObj[line.substring(0, colon).trim()] = line
|
||||||
.trim();
|
.substring(colon + 1)
|
||||||
});
|
.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload.headers = hdrObj;
|
||||||
|
} else {
|
||||||
|
// 'none' / 'oauth_user' — clear server-side static headers state.
|
||||||
|
payload.headers = {};
|
||||||
}
|
}
|
||||||
payload.headers = hdrObj;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authType === "oauth_user") {
|
||||||
|
payload.oauth_authorization_server_url = document
|
||||||
|
.getElementById("mcp-oauth-as-url")
|
||||||
|
.value.trim();
|
||||||
|
payload.oauth_registration_mode = document.getElementById(
|
||||||
|
"mcp-oauth-registration",
|
||||||
|
).value;
|
||||||
|
payload.oauth_client_id = document
|
||||||
|
.getElementById("mcp-oauth-client-id")
|
||||||
|
.value.trim();
|
||||||
|
payload.oauth_scopes = document
|
||||||
|
.getElementById("mcp-oauth-scopes")
|
||||||
|
.value.trim();
|
||||||
|
payload.oauth_audience = document
|
||||||
|
.getElementById("mcp-oauth-audience")
|
||||||
|
.value.trim();
|
||||||
|
var secret = document.getElementById("mcp-oauth-client-secret").value;
|
||||||
|
// Submit only when the operator typed a value; redacted in audit log.
|
||||||
|
if (secret) payload.oauth_client_secret = secret;
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3677,6 +3677,114 @@
|
|||||||
placeholder="Authorization: Bearer ..."
|
placeholder="Authorization: Bearer ..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset
|
||||||
|
id="mcp-auth-section"
|
||||||
|
style="
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<legend style="font-size: 12px; padding: 0 6px">
|
||||||
|
Multitenant Authorization
|
||||||
|
</legend>
|
||||||
|
<label
|
||||||
|
style="display: block; margin: 4px 0; font-weight: 400; font-size: 13px"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mcp-auth-type"
|
||||||
|
id="mcp-auth-none"
|
||||||
|
value="none"
|
||||||
|
onchange="toggleMcpAuthFields()"
|
||||||
|
style="margin-right: 6px"
|
||||||
|
/>No authorization
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style="display: block; margin: 4px 0; font-weight: 400; font-size: 13px"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mcp-auth-type"
|
||||||
|
id="mcp-auth-static"
|
||||||
|
value="static"
|
||||||
|
onchange="toggleMcpAuthFields()"
|
||||||
|
checked
|
||||||
|
style="margin-right: 6px"
|
||||||
|
/>Static headers (single shared identity)
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style="display: block; margin: 4px 0; font-weight: 400; font-size: 13px"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="mcp-auth-type"
|
||||||
|
id="mcp-auth-oauth"
|
||||||
|
value="oauth_user"
|
||||||
|
onchange="toggleMcpAuthFields()"
|
||||||
|
style="margin-right: 6px"
|
||||||
|
/>Per-user OAuth 2.1 (recommended)
|
||||||
|
</label>
|
||||||
|
<div id="mcp-oauth-fields" style="display: none; margin-top: 8px">
|
||||||
|
<label for="mcp-oauth-as-url"
|
||||||
|
>Authorization Server URL
|
||||||
|
<span style="font-weight: 400; text-transform: none"
|
||||||
|
>(optional — override discovery when MCP server URL is not the
|
||||||
|
OAuth issuer)</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mcp-oauth-as-url"
|
||||||
|
placeholder="https://auth.example.com"
|
||||||
|
/>
|
||||||
|
<label for="mcp-oauth-registration">Client Registration</label>
|
||||||
|
<select id="mcp-oauth-registration">
|
||||||
|
<option value="preregistered">preregistered</option>
|
||||||
|
<option value="dcr">dcr (Dynamic Client Registration)</option>
|
||||||
|
</select>
|
||||||
|
<label for="mcp-oauth-client-id">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mcp-oauth-client-id"
|
||||||
|
placeholder="(operator-issued client_id)"
|
||||||
|
/>
|
||||||
|
<label for="mcp-oauth-client-secret"
|
||||||
|
>Client Secret
|
||||||
|
<span style="font-weight: 400; text-transform: none"
|
||||||
|
>(write-only, never displayed)</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="mcp-oauth-client-secret"
|
||||||
|
placeholder="***"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<label for="mcp-oauth-scopes"
|
||||||
|
>Scopes
|
||||||
|
<span style="font-weight: 400; text-transform: none"
|
||||||
|
>(space-separated)</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mcp-oauth-scopes"
|
||||||
|
placeholder="openid profile"
|
||||||
|
/>
|
||||||
|
<label for="mcp-oauth-audience"
|
||||||
|
>Audience
|
||||||
|
<span style="font-weight: 400; text-transform: none"
|
||||||
|
>(auto-populated from URL)</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="mcp-oauth-audience"
|
||||||
|
placeholder="https://mcp.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div style="display: flex; gap: 20px; margin-top: 14px">
|
<div style="display: flex; gap: 20px; margin-top: 14px">
|
||||||
<label style="margin: 0; font-size: 12px; color: var(--fg-dim)"
|
<label style="margin: 0; font-size: 12px; color: var(--fg-dim)"
|
||||||
><input
|
><input
|
||||||
|
|||||||
@@ -3705,6 +3705,14 @@ class PostgreSQLBackend:
|
|||||||
registry_name: str | None = None,
|
registry_name: str | None = None,
|
||||||
registry_version: str = "",
|
registry_version: str = "",
|
||||||
registry_meta: str = "{}",
|
registry_meta: str = "{}",
|
||||||
|
auth_type: str = "static",
|
||||||
|
oauth_client_id: str | None = None,
|
||||||
|
oauth_client_secret_ct: bytes | None = None,
|
||||||
|
oauth_scopes: str | None = None,
|
||||||
|
oauth_audience: str | None = None,
|
||||||
|
oauth_registration_mode: str | None = None,
|
||||||
|
oauth_authorization_server_url: str | None = None,
|
||||||
|
oauth_as_issuer_cached: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
@@ -3727,6 +3735,14 @@ class PostgreSQLBackend:
|
|||||||
registry_name=registry_name,
|
registry_name=registry_name,
|
||||||
registry_version=registry_version,
|
registry_version=registry_version,
|
||||||
registry_meta=registry_meta,
|
registry_meta=registry_meta,
|
||||||
|
auth_type=auth_type,
|
||||||
|
oauth_client_id=oauth_client_id,
|
||||||
|
oauth_client_secret_ct=oauth_client_secret_ct,
|
||||||
|
oauth_scopes=oauth_scopes,
|
||||||
|
oauth_audience=oauth_audience,
|
||||||
|
oauth_registration_mode=oauth_registration_mode,
|
||||||
|
oauth_authorization_server_url=oauth_authorization_server_url,
|
||||||
|
oauth_as_issuer_cached=oauth_as_issuer_cached,
|
||||||
created=now,
|
created=now,
|
||||||
updated=now,
|
updated=now,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ class OIDCPendingState(TypedDict):
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class MCPUserToken(TypedDict):
|
||||||
|
"""Row shape returned by per-(user, MCP server) OAuth token lookups.
|
||||||
|
|
||||||
|
See ``docs/design/oauth-mcp.md`` §5.1. ``access_token_ct`` and
|
||||||
|
``refresh_token_ct`` are Fernet ciphertext blobs; the storage layer
|
||||||
|
returns them verbatim and ``MCPTokenStore`` (Phase 3) handles
|
||||||
|
encrypt/decrypt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
server_name: str
|
||||||
|
access_token_ct: bytes
|
||||||
|
refresh_token_ct: bytes | None
|
||||||
|
expires_at: str | None
|
||||||
|
scopes: str | None
|
||||||
|
as_issuer: str
|
||||||
|
audience: str
|
||||||
|
created: str
|
||||||
|
last_refreshed: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class MCPOAuthPendingState(TypedDict):
|
||||||
|
"""Row shape returned when popping a pending MCP OAuth flow state."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
user_id: str
|
||||||
|
server_name: str
|
||||||
|
code_verifier: str
|
||||||
|
return_url: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class StorageBackend(Protocol):
|
class StorageBackend(Protocol):
|
||||||
"""Protocol that every storage backend adapter must implement.
|
"""Protocol that every storage backend adapter must implement.
|
||||||
@@ -1585,6 +1617,14 @@ class StorageBackend(Protocol):
|
|||||||
registry_name: str | None = None,
|
registry_name: str | None = None,
|
||||||
registry_version: str = "",
|
registry_version: str = "",
|
||||||
registry_meta: str = "{}",
|
registry_meta: str = "{}",
|
||||||
|
auth_type: str = "static",
|
||||||
|
oauth_client_id: str | None = None,
|
||||||
|
oauth_client_secret_ct: bytes | None = None,
|
||||||
|
oauth_scopes: str | None = None,
|
||||||
|
oauth_audience: str | None = None,
|
||||||
|
oauth_registration_mode: str | None = None,
|
||||||
|
oauth_authorization_server_url: str | None = None,
|
||||||
|
oauth_as_issuer_cached: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create an MCP server definition. No-op if server_id already exists."""
|
"""Create an MCP server definition. No-op if server_id already exists."""
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -607,6 +607,19 @@ mcp_servers = sa.Table(
|
|||||||
sa.Column("registry_name", sa.Text, nullable=True),
|
sa.Column("registry_name", sa.Text, nullable=True),
|
||||||
sa.Column("registry_version", sa.Text, nullable=False, server_default=""),
|
sa.Column("registry_version", sa.Text, nullable=False, server_default=""),
|
||||||
sa.Column("registry_meta", sa.Text, nullable=False, server_default="{}"),
|
sa.Column("registry_meta", sa.Text, nullable=False, server_default="{}"),
|
||||||
|
# Per-(user, server) OAuth 2.1 — see docs/design/oauth-mcp.md §5.2.
|
||||||
|
# `auth_type` is one of: 'none', 'static', 'oauth_user'. The other
|
||||||
|
# `oauth_*` columns are NULL when auth_type != 'oauth_user'.
|
||||||
|
# `oauth_client_secret_ct` is Fernet ciphertext; never decrypted on
|
||||||
|
# the read path (write-only field, masked as "***" in responses).
|
||||||
|
sa.Column("auth_type", sa.Text, nullable=False, server_default="static"),
|
||||||
|
sa.Column("oauth_client_id", sa.Text, nullable=True),
|
||||||
|
sa.Column("oauth_client_secret_ct", sa.LargeBinary, nullable=True),
|
||||||
|
sa.Column("oauth_scopes", sa.Text, nullable=True),
|
||||||
|
sa.Column("oauth_audience", sa.Text, nullable=True),
|
||||||
|
sa.Column("oauth_registration_mode", sa.Text, nullable=True),
|
||||||
|
sa.Column("oauth_authorization_server_url", sa.Text, nullable=True),
|
||||||
|
sa.Column("oauth_as_issuer_cached", sa.Text, nullable=True),
|
||||||
sa.Column("created", sa.Text, nullable=False),
|
sa.Column("created", sa.Text, nullable=False),
|
||||||
sa.Column("updated", sa.Text, nullable=False),
|
sa.Column("updated", sa.Text, nullable=False),
|
||||||
)
|
)
|
||||||
@@ -693,6 +706,41 @@ oidc_pending_states = sa.Table(
|
|||||||
sa.Column("created_at", sa.Text, nullable=False),
|
sa.Column("created_at", sa.Text, nullable=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MCP per-(user, server) OAuth tokens and pending authorization-flow state.
|
||||||
|
# See docs/design/oauth-mcp.md §5.1. No FKs at the schema level (matches
|
||||||
|
# `oidc_*` tables; tests avoid orphan rows via fixtures).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mcp_user_tokens = sa.Table(
|
||||||
|
"mcp_user_tokens",
|
||||||
|
metadata,
|
||||||
|
sa.Column("user_id", sa.Text, nullable=False),
|
||||||
|
sa.Column("server_name", sa.Text, nullable=False),
|
||||||
|
sa.Column("access_token_ct", sa.LargeBinary, nullable=False),
|
||||||
|
sa.Column("refresh_token_ct", sa.LargeBinary, nullable=True),
|
||||||
|
sa.Column("expires_at", sa.Text, nullable=True),
|
||||||
|
sa.Column("scopes", sa.Text, nullable=True),
|
||||||
|
sa.Column("as_issuer", sa.Text, nullable=False),
|
||||||
|
sa.Column("audience", sa.Text, nullable=False),
|
||||||
|
sa.Column("created", sa.Text, nullable=False),
|
||||||
|
sa.Column("last_refreshed", sa.Text, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("user_id", "server_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_oauth_pending = sa.Table(
|
||||||
|
"mcp_oauth_pending",
|
||||||
|
metadata,
|
||||||
|
sa.Column("state", sa.Text, primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text, nullable=False),
|
||||||
|
sa.Column("server_name", sa.Text, nullable=False),
|
||||||
|
sa.Column("code_verifier", sa.Text, nullable=False),
|
||||||
|
sa.Column("return_url", sa.Text, nullable=False),
|
||||||
|
sa.Column("created_at", sa.Text, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
sa.Index("idx_mcp_pending_created", mcp_oauth_pending.c.created_at)
|
||||||
|
|
||||||
# ── TLS / ACME (lacme integration) ──────────────────────────────────────────
|
# ── TLS / ACME (lacme integration) ──────────────────────────────────────────
|
||||||
|
|
||||||
tls_account_keys = sa.Table(
|
tls_account_keys = sa.Table(
|
||||||
|
|||||||
@@ -3856,6 +3856,14 @@ class SQLiteBackend:
|
|||||||
registry_name: str | None = None,
|
registry_name: str | None = None,
|
||||||
registry_version: str = "",
|
registry_version: str = "",
|
||||||
registry_meta: str = "{}",
|
registry_meta: str = "{}",
|
||||||
|
auth_type: str = "static",
|
||||||
|
oauth_client_id: str | None = None,
|
||||||
|
oauth_client_secret_ct: bytes | None = None,
|
||||||
|
oauth_scopes: str | None = None,
|
||||||
|
oauth_audience: str | None = None,
|
||||||
|
oauth_registration_mode: str | None = None,
|
||||||
|
oauth_authorization_server_url: str | None = None,
|
||||||
|
oauth_as_issuer_cached: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
@@ -3877,6 +3885,14 @@ class SQLiteBackend:
|
|||||||
"registry_name": registry_name,
|
"registry_name": registry_name,
|
||||||
"registry_version": registry_version,
|
"registry_version": registry_version,
|
||||||
"registry_meta": registry_meta,
|
"registry_meta": registry_meta,
|
||||||
|
"auth_type": auth_type,
|
||||||
|
"oauth_client_id": oauth_client_id,
|
||||||
|
"oauth_client_secret_ct": oauth_client_secret_ct,
|
||||||
|
"oauth_scopes": oauth_scopes,
|
||||||
|
"oauth_audience": oauth_audience,
|
||||||
|
"oauth_registration_mode": oauth_registration_mode,
|
||||||
|
"oauth_authorization_server_url": oauth_authorization_server_url,
|
||||||
|
"oauth_as_issuer_cached": oauth_as_issuer_cached,
|
||||||
"created": now,
|
"created": now,
|
||||||
"updated": now,
|
"updated": now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ MCP_SERVER_MUTABLE = frozenset(
|
|||||||
"registry_name",
|
"registry_name",
|
||||||
"registry_version",
|
"registry_version",
|
||||||
"registry_meta",
|
"registry_meta",
|
||||||
|
"auth_type",
|
||||||
|
"oauth_client_id",
|
||||||
|
"oauth_scopes",
|
||||||
|
"oauth_audience",
|
||||||
|
"oauth_registration_mode",
|
||||||
|
"oauth_authorization_server_url",
|
||||||
|
"oauth_as_issuer_cached",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
MODEL_DEFINITION_MUTABLE = frozenset(
|
MODEL_DEFINITION_MUTABLE = frozenset(
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Add OAuth-MCP schema (Phase 2).
|
||||||
|
|
||||||
|
Adds two new tables (``mcp_user_tokens``, ``mcp_oauth_pending``) and
|
||||||
|
eight new columns on ``mcp_servers`` to support per-(user, server)
|
||||||
|
OAuth 2.1 + PKCE authorization for MCP servers. See
|
||||||
|
``docs/design/oauth-mcp.md`` §5.1 / §5.2.
|
||||||
|
|
||||||
|
Existing rows continue working: every new column is either nullable or
|
||||||
|
defaults to ``'static'`` (the existing behavior). After the new
|
||||||
|
``auth_type`` column lands, rows with empty ``headers`` are normalized
|
||||||
|
to ``auth_type='none'`` so the operator UI can hide the static-headers
|
||||||
|
field for those.
|
||||||
|
|
||||||
|
Revision ID: 049
|
||||||
|
Revises: 048
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "049"
|
||||||
|
down_revision = "048"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"mcp_user_tokens",
|
||||||
|
sa.Column("user_id", sa.Text, nullable=False),
|
||||||
|
sa.Column("server_name", sa.Text, nullable=False),
|
||||||
|
sa.Column("access_token_ct", sa.LargeBinary, nullable=False),
|
||||||
|
sa.Column("refresh_token_ct", sa.LargeBinary, nullable=True),
|
||||||
|
sa.Column("expires_at", sa.Text, nullable=True),
|
||||||
|
sa.Column("scopes", sa.Text, nullable=True),
|
||||||
|
sa.Column("as_issuer", sa.Text, nullable=False),
|
||||||
|
sa.Column("audience", sa.Text, nullable=False),
|
||||||
|
sa.Column("created", sa.Text, nullable=False),
|
||||||
|
sa.Column("last_refreshed", sa.Text, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("user_id", "server_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mcp_oauth_pending",
|
||||||
|
sa.Column("state", sa.Text, primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Text, nullable=False),
|
||||||
|
sa.Column("server_name", sa.Text, nullable=False),
|
||||||
|
sa.Column("code_verifier", sa.Text, nullable=False),
|
||||||
|
sa.Column("return_url", sa.Text, nullable=False),
|
||||||
|
sa.Column("created_at", sa.Text, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_mcp_pending_created", "mcp_oauth_pending", ["created_at"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"mcp_servers",
|
||||||
|
sa.Column("auth_type", sa.Text, nullable=False, server_default="static"),
|
||||||
|
)
|
||||||
|
op.add_column("mcp_servers", sa.Column("oauth_client_id", sa.Text, nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"mcp_servers",
|
||||||
|
sa.Column("oauth_client_secret_ct", sa.LargeBinary, nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("mcp_servers", sa.Column("oauth_scopes", sa.Text, nullable=True))
|
||||||
|
op.add_column("mcp_servers", sa.Column("oauth_audience", sa.Text, nullable=True))
|
||||||
|
op.add_column("mcp_servers", sa.Column("oauth_registration_mode", sa.Text, nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"mcp_servers",
|
||||||
|
sa.Column("oauth_authorization_server_url", sa.Text, nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("mcp_servers", sa.Column("oauth_as_issuer_cached", sa.Text, nullable=True))
|
||||||
|
|
||||||
|
# Normalize the no-static-headers case after the column exists.
|
||||||
|
# Restricted to streamable-http rows — for stdio rows the column
|
||||||
|
# value is opaque (auth_type is meaningless when there is no HTTP
|
||||||
|
# transport to attach headers to), so leave them at the 'static'
|
||||||
|
# default.
|
||||||
|
# NOTE: lossy on downgrade — once a row is rewritten to 'none', the
|
||||||
|
# previous distinction (server_default 'static' vs explicit 'none')
|
||||||
|
# cannot be recovered from the DB alone.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE mcp_servers SET auth_type = 'none' "
|
||||||
|
"WHERE transport = 'streamable-http' "
|
||||||
|
"AND (headers IS NULL OR headers = '' OR headers = '{}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse order of upgrade; column drops mirror add_column calls.
|
||||||
|
op.drop_column("mcp_servers", "oauth_as_issuer_cached")
|
||||||
|
op.drop_column("mcp_servers", "oauth_authorization_server_url")
|
||||||
|
op.drop_column("mcp_servers", "oauth_registration_mode")
|
||||||
|
op.drop_column("mcp_servers", "oauth_audience")
|
||||||
|
op.drop_column("mcp_servers", "oauth_scopes")
|
||||||
|
op.drop_column("mcp_servers", "oauth_client_secret_ct")
|
||||||
|
op.drop_column("mcp_servers", "oauth_client_id")
|
||||||
|
op.drop_column("mcp_servers", "auth_type")
|
||||||
|
|
||||||
|
op.drop_index("idx_mcp_pending_created", table_name="mcp_oauth_pending")
|
||||||
|
op.drop_table("mcp_oauth_pending")
|
||||||
|
op.drop_table("mcp_user_tokens")
|
||||||
Reference in New Issue
Block a user