feat(mcp): OAuth per-user connectors (§15) — providers, PKCE copy-paste flow, env credential delivery
- oauth_providers registry table (per-provider client creds) + db/oauth_providers.rs - mcp/oauth.rs: authorization-code + PKCE S256, RAM-only TTL'd flow store, copy-paste consent - mcp/install.rs + verify.rs: connector file install + manifest verification - activate persists a pending row (needs_oauth); /mcp/oauth/start + /complete exchange code for refresh token - credential delivery via env var on docker exec (google_authorized_user JSON), never on disk - mcp_catalog/mcp_user_servers: additive OAuth columns (ensure_column), catalog_name/oauth_provider/deliver_json bare TEXT snapshots - frontend: connector-detail.js (OAuth login panel), shared/connector-common.js, connectors.js admin Sign-in providers modal - API: /mcp/providers (admin OAuth creds), /mcp/oauth/start|complete - .gitignore: add /homes/ (instance data), /connectors/, /reset.sh; drop stale /secrets/
This commit is contained in:
@@ -26,14 +26,32 @@ pub struct McpCatalogRow {
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
/// local_script: the vetted source path under `./scripts`.
|
||||
/// local_script: the vetted entry file, as `<connector>/<file>` under
|
||||
/// `./connectors` (see [`crate::mcp::install`]).
|
||||
pub script_path: Option<String>,
|
||||
/// Names of the env/secret keys the activation UI must collect (never values).
|
||||
/// JSON array of `{name,label,description,required,secret,example,default}` objects
|
||||
/// describing the env/secret fields the activation UI must collect (never values).
|
||||
pub config_schema_json: Option<String>,
|
||||
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'. Only 'none'/'api_key' are wired now.
|
||||
/// 'none'|'api_key'|'oauth'|'qr'|'ssh_key'.
|
||||
pub auth_kind: String,
|
||||
/// oauth: slug into `oauth_providers.name` (which app to consent to).
|
||||
pub oauth_provider: Option<String>,
|
||||
/// oauth: JSON array of the scopes this connector requests at consent.
|
||||
pub oauth_scopes_json: Option<String>,
|
||||
/// oauth: JSON `{as,format,env,path}` — how Skald delivers the obtained
|
||||
/// credential to the connector's server process (§15).
|
||||
pub deliver_json: Option<String>,
|
||||
/// JSON array of role ids allowed to activate this; NULL = all roles (§15).
|
||||
pub role_filter: Option<String>,
|
||||
/// Shell command run before persisting an activation (verify-before-save).
|
||||
pub verify_command: Option<String>,
|
||||
/// Script file the verify command references (e.g. `verify.py`), if any.
|
||||
pub verify_script_path: Option<String>,
|
||||
/// Icon file *inside* `./connectors/<name>/`, if the feed shipped one. Stored
|
||||
/// rather than derived because the manifest names its icons freely (`.png` for
|
||||
/// one connector, `.svg` for the next), and the browser cannot guess.
|
||||
pub icon_small_path: Option<String>,
|
||||
pub icon_large_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -65,11 +83,21 @@ impl McpCatalogRow {
|
||||
Some(roles) => roles.iter().any(|r| r == role_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The OAuth scopes this connector requests at consent, or empty when it is not
|
||||
/// an OAuth connector.
|
||||
pub fn oauth_scopes(&self) -> Vec<String> {
|
||||
self.oauth_scopes_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, scope, source, transport, command, args_json, env_json, url, \
|
||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, \
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
|
||||
deliver_json, role_filter, verify_command, \
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
|
||||
description, created_at \
|
||||
FROM mcp_catalog";
|
||||
|
||||
@@ -122,7 +150,14 @@ pub struct UpsertCatalog<'a> {
|
||||
pub script_path: Option<&'a str>,
|
||||
pub config_schema_json: Option<String>,
|
||||
pub auth_kind: &'a str,
|
||||
pub oauth_provider: Option<&'a str>,
|
||||
pub oauth_scopes_json: Option<String>,
|
||||
pub deliver_json: Option<String>,
|
||||
pub role_filter: Option<String>,
|
||||
pub verify_command: Option<&'a str>,
|
||||
pub verify_script_path: Option<&'a str>,
|
||||
pub icon_small_path: Option<&'a str>,
|
||||
pub icon_large_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
@@ -131,8 +166,10 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_catalog
|
||||
(name, scope, source, transport, command, args_json, env_json, url,
|
||||
script_path, config_schema_json, auth_kind, role_filter, friendly_name, description)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
|
||||
deliver_json, role_filter, verify_command,
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, description)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
@@ -144,7 +181,18 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
script_path = excluded.script_path,
|
||||
config_schema_json = excluded.config_schema_json,
|
||||
auth_kind = excluded.auth_kind,
|
||||
oauth_provider = excluded.oauth_provider,
|
||||
oauth_scopes_json = excluded.oauth_scopes_json,
|
||||
deliver_json = excluded.deliver_json,
|
||||
role_filter = excluded.role_filter,
|
||||
verify_command = excluded.verify_command,
|
||||
verify_script_path = excluded.verify_script_path,
|
||||
-- Icons belong to whoever installed the files, not to whoever last
|
||||
-- edited the row: COALESCE keeps them when an admin saves the catalog
|
||||
-- form (which knows nothing about icons and would otherwise blank
|
||||
-- them), while a reinstall still updates them.
|
||||
icon_small_path = COALESCE(excluded.icon_small_path, mcp_catalog.icon_small_path),
|
||||
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description
|
||||
RETURNING id",
|
||||
@@ -160,7 +208,14 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
.bind(e.script_path)
|
||||
.bind(e.config_schema_json)
|
||||
.bind(e.auth_kind)
|
||||
.bind(e.oauth_provider)
|
||||
.bind(e.oauth_scopes_json)
|
||||
.bind(e.deliver_json)
|
||||
.bind(e.role_filter)
|
||||
.bind(e.verify_command)
|
||||
.bind(e.verify_script_path)
|
||||
.bind(e.icon_small_path)
|
||||
.bind(e.icon_large_path)
|
||||
.bind(e.friendly_name)
|
||||
.bind(e.description)
|
||||
.fetch_one(pool)
|
||||
|
||||
@@ -14,18 +14,22 @@ use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpGlobalServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub catalog_name: Option<String>,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub catalog_name: Option<String>,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
/// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
|
||||
pub verify_command: Option<String>,
|
||||
/// Absolute host path of the verify script, if any.
|
||||
pub verify_script_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpGlobalServerRow {
|
||||
@@ -44,7 +48,7 @@ impl McpGlobalServerRow {
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, catalog_name, transport, command, args_json, env_json, url, \
|
||||
api_key, friendly_name, description, enabled \
|
||||
api_key, verify_command, verify_script_path, friendly_name, description, enabled \
|
||||
FROM mcp_global_servers";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
@@ -82,34 +86,39 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpGlob
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct UpsertGlobal<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub verify_command: Option<&'a str>,
|
||||
pub verify_script_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_global_servers
|
||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key, friendly_name, description, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
|
||||
(name, catalog_name, transport, command, args_json, env_json, url, api_key,
|
||||
verify_command, verify_script_path, friendly_name, description, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
catalog_name = excluded.catalog_name,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
enabled = 1
|
||||
catalog_name = excluded.catalog_name,
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
verify_command = excluded.verify_command,
|
||||
verify_script_path = excluded.verify_script_path,
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
enabled = 1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(p.name)
|
||||
@@ -120,6 +129,8 @@ pub async fn upsert(pool: &SqlitePool, p: UpsertGlobal<'_>) -> Result<i64> {
|
||||
.bind(p.env_json)
|
||||
.bind(p.url)
|
||||
.bind(p.api_key)
|
||||
.bind(p.verify_command)
|
||||
.bind(p.verify_script_path)
|
||||
.bind(p.friendly_name)
|
||||
.bind(p.description)
|
||||
.fetch_one(pool)
|
||||
|
||||
@@ -15,24 +15,34 @@ use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct McpUserServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Bare snapshot of the originating `mcp_catalog.name`; NULL for a
|
||||
/// self-registered remote.
|
||||
pub catalog_name: Option<String>,
|
||||
pub catalog_name: Option<String>,
|
||||
/// 'remote' | 'local_script'.
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub source: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
/// Per-user secret. For an OAuth connector this holds the refresh token; empty
|
||||
/// until the OAuth flow completes (`auth_state='pending'`).
|
||||
pub api_key: Option<String>,
|
||||
/// oauth: snapshot of the catalog's `oauth_provider` (which app issued the token).
|
||||
pub oauth_provider: Option<String>,
|
||||
/// oauth: snapshot of the catalog's delivery spec `{as,format,env,path}`.
|
||||
pub deliver_json: Option<String>,
|
||||
/// Container path of the copied script, for a `local_script`.
|
||||
pub script_rel_path: Option<String>,
|
||||
/// 'pending' | 'ready' — the interactive-auth gate ('ready' while api-key).
|
||||
pub auth_state: String,
|
||||
pub enabled: bool,
|
||||
pub script_rel_path: Option<String>,
|
||||
/// Snapshot of `mcp_catalog.verify_command` (NULL = no test).
|
||||
pub verify_command: Option<String>,
|
||||
/// Container path of the verify script, if any.
|
||||
pub verify_script_rel_path: Option<String>,
|
||||
/// 'pending' | 'ready' — the verify-before-save gate.
|
||||
pub auth_state: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpUserServerRow {
|
||||
@@ -47,11 +57,19 @@ impl McpUserServerRow {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The credential delivery spec (`{as,format,env,path}`), if this is an OAuth
|
||||
/// connector that snapshotted one.
|
||||
pub fn deliver(&self) -> Option<crate::mcp::DeliverSpec> {
|
||||
self.deliver_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, catalog_name, source, transport, command, args_json, env_json, url, \
|
||||
api_key, script_rel_path, auth_state, enabled \
|
||||
api_key, oauth_provider, deliver_json, script_rel_path, verify_command, \
|
||||
verify_script_rel_path, auth_state, enabled \
|
||||
FROM mcp_user_servers";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
@@ -93,24 +111,30 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> Result<Option<McpUser
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct InsertUserServer<'a> {
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub script_rel_path: Option<&'a str>,
|
||||
pub auth_state: &'a str,
|
||||
pub name: &'a str,
|
||||
pub catalog_name: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub oauth_provider: Option<&'a str>,
|
||||
pub deliver_json: Option<String>,
|
||||
pub script_rel_path: Option<&'a str>,
|
||||
pub verify_command: Option<&'a str>,
|
||||
pub verify_script_rel_path: Option<&'a str>,
|
||||
pub auth_state: &'a str,
|
||||
}
|
||||
|
||||
pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO mcp_user_servers
|
||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key, script_rel_path, auth_state, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1)",
|
||||
(name, catalog_name, source, transport, command, args_json, env_json, url, api_key,
|
||||
oauth_provider, deliver_json, script_rel_path, verify_command, verify_script_rel_path,
|
||||
auth_state, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
|
||||
)
|
||||
.bind(s.name)
|
||||
.bind(s.catalog_name)
|
||||
@@ -121,7 +145,11 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
.bind(s.env_json)
|
||||
.bind(s.url)
|
||||
.bind(s.api_key)
|
||||
.bind(s.oauth_provider)
|
||||
.bind(s.deliver_json)
|
||||
.bind(s.script_rel_path)
|
||||
.bind(s.verify_command)
|
||||
.bind(s.verify_script_rel_path)
|
||||
.bind(s.auth_state)
|
||||
.execute(pool)
|
||||
.await?
|
||||
@@ -129,6 +157,17 @@ pub async fn insert(pool: &SqlitePool, s: InsertUserServer<'_>) -> Result<i64> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Stores a freshly-obtained OAuth refresh token and flips the connector to
|
||||
/// `ready`, in one write — the completion of the §15 login flow.
|
||||
pub async fn set_oauth_token(pool: &SqlitePool, id: i64, refresh_token: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_user_servers SET api_key = ?1, auth_state = 'ready' WHERE id = ?2")
|
||||
.bind(refresh_token)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_user_servers SET enabled = ?1 WHERE id = ?2")
|
||||
.bind(enabled as i64)
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod mcp_global_access;
|
||||
pub mod mcp_global_servers;
|
||||
pub mod mcp_user_servers;
|
||||
pub mod memory_docs;
|
||||
pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
@@ -155,6 +156,21 @@ pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Adds a nullable column if it is not already present, so a purely **additive**
|
||||
/// schema change lands on an existing database without a wipe. Greenfield still
|
||||
/// permits a clean recreate (§0); this only spares an existing box's data when the
|
||||
/// change is additive, and is a no-op on a fresh DB where the column already exists
|
||||
/// in the `CREATE TABLE`. The "duplicate column name" error means it's already there.
|
||||
async fn ensure_column(pool: &SqlitePool, table: &str, column: &str, decl: &str) -> Result<()> {
|
||||
let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {decl}");
|
||||
if let Err(e) = sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
|
||||
if !e.to_string().contains("duplicate column name") {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Registry tables ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Instance-wide, readable without any user key: the directory you must open
|
||||
@@ -442,10 +458,17 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
script_path TEXT, -- local_script: source under ./scripts
|
||||
config_schema_json TEXT, -- names of env/secret keys the UI must collect
|
||||
script_path TEXT, -- local_script: entry file, as <connector>/<file> under ./connectors
|
||||
config_schema_json TEXT, -- env[] entries (objects) the UI must collect
|
||||
auth_kind TEXT NOT NULL DEFAULT 'none', -- 'none'|'api_key'|'oauth'|'qr'|'ssh_key'
|
||||
oauth_provider TEXT, -- oauth: slug into oauth_providers.name
|
||||
oauth_scopes_json TEXT, -- oauth: JSON array of scopes requested at consent
|
||||
deliver_json TEXT, -- oauth: {as,format,env,path} credential delivery spec
|
||||
role_filter TEXT, -- JSON array of role ids; NULL = all
|
||||
verify_command TEXT, -- shell command run before persisting an activation
|
||||
verify_script_path TEXT, -- script file the verify command references, if any
|
||||
icon_small_path TEXT, -- icon file inside ./connectors/<name>/, if the feed shipped one
|
||||
icon_large_path TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -453,6 +476,10 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// OAuth columns are additive (§15) — reach an already-created catalog in place.
|
||||
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
|
||||
|
||||
// Concrete globally-active connectors (shared, stateless — web-search etc.).
|
||||
// They run on the HOST. The global secret (admin's API key) is fine here:
|
||||
@@ -460,19 +487,21 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
// FK (both in this file) — allowed.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_global_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT REFERENCES mcp_catalog(name),
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
verify_command TEXT, -- snapshot of mcp_catalog.verify_command
|
||||
verify_script_path TEXT, -- absolute host path of the verify script, if any
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -503,6 +532,32 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// OAuth providers for per-user connectors (blueprint §15). One row per identity
|
||||
// provider (Google, …), referenced by name from `mcp_catalog.oauth_provider`. A
|
||||
// single app covers every service of that provider (Gmail, Calendar, Drive) —
|
||||
// client credentials are keyed on the provider, scopes on the connector.
|
||||
//
|
||||
// Registry table (`system.db`): `client_secret` is a household/global secret the
|
||||
// admin owns (§4/§15b), not a per-user one, so it belongs here in the admin-
|
||||
// readable file. The per-user refresh tokens each activation obtains never land
|
||||
// here — they go, encrypted, into the user's `mcp_user_servers.api_key`.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS oauth_providers (
|
||||
name TEXT PRIMARY KEY, -- slug referenced by mcp_catalog.oauth_provider
|
||||
display_name TEXT NOT NULL,
|
||||
auth_url TEXT NOT NULL, -- authorization endpoint
|
||||
token_url TEXT NOT NULL, -- token endpoint
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL, -- copy-paste landing page (oauth/show.html)
|
||||
extra_params TEXT, -- JSON of extra auth params (access_type, prompt, …)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -742,24 +797,31 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
// copied into the bind-mounted home (`script_rel_path`).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_user_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||
script_rel_path TEXT, -- container path for a local_script
|
||||
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
catalog_name TEXT, -- bare ref to mcp_catalog.name; NULL = self-registered remote
|
||||
source TEXT NOT NULL, -- 'remote' | 'local_script'
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT, -- per-user secret / OAuth refresh token
|
||||
oauth_provider TEXT, -- oauth: snapshot of catalog oauth_provider
|
||||
deliver_json TEXT, -- oauth: snapshot of catalog credential delivery spec
|
||||
script_rel_path TEXT, -- container path for a local_script
|
||||
verify_command TEXT, -- snapshot of mcp_catalog.verify_command
|
||||
verify_script_rel_path TEXT, -- container path of the verify script, if any
|
||||
auth_state TEXT NOT NULL DEFAULT 'ready', -- 'pending' | 'ready'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// OAuth columns are additive (§15) — reach an already-created table in place.
|
||||
ensure_column(pool, "mcp_user_servers", "oauth_provider", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_user_servers", "deliver_json", "TEXT").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS sources (
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! OAuth identity providers for per-user connectors (blueprint §15).
|
||||
//!
|
||||
//! Registry table in `system.db`: one row per provider (Google, …), keyed by a
|
||||
//! stable slug that `mcp_catalog.oauth_provider` references. A single provider row
|
||||
//! covers every service that provider exposes (Gmail, Calendar, Drive) — the
|
||||
//! client credentials live here, the per-connector scopes in the catalog.
|
||||
//!
|
||||
//! `client_secret` is a household/global secret the admin owns (§4/§15b), so it is
|
||||
//! fine in the admin-readable file. Per-user refresh tokens never land here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct OauthProviderRow {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
/// Never leaves the process for the browser — see [`OauthProviderView`].
|
||||
pub client_secret: String,
|
||||
pub redirect_uri: String,
|
||||
pub extra_params: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl OauthProviderRow {
|
||||
/// Extra authorization-endpoint params (e.g. `access_type=offline`,
|
||||
/// `prompt=consent`) merged into the consent URL. Google needs both to return a
|
||||
/// refresh token; a provider that needs neither leaves this NULL.
|
||||
pub fn extra(&self) -> HashMap<String, String> {
|
||||
self.extra_params.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider as the admin UI renders it — **without** `client_secret`. The list
|
||||
/// endpoint reaches the browser, and the secret has no business there.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OauthProviderView {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
pub extra_params: Option<String>,
|
||||
/// So the admin sees a secret is set without the value crossing the wire.
|
||||
pub has_client_secret: bool,
|
||||
}
|
||||
|
||||
impl From<OauthProviderRow> for OauthProviderView {
|
||||
fn from(r: OauthProviderRow) -> Self {
|
||||
OauthProviderView {
|
||||
has_client_secret: !r.client_secret.is_empty(),
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
auth_url: r.auth_url,
|
||||
token_url: r.token_url,
|
||||
client_id: r.client_id,
|
||||
redirect_uri: r.redirect_uri,
|
||||
extra_params: r.extra_params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT name, display_name, auth_url, token_url, client_id, client_secret, \
|
||||
redirect_uri, extra_params, created_at, updated_at \
|
||||
FROM oauth_providers";
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<OauthProviderRow>> {
|
||||
let rows = sqlx::query_as::<_, OauthProviderRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, name: &str) -> Result<Option<OauthProviderRow>> {
|
||||
let row = sqlx::query_as::<_, OauthProviderRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE name = ?")))
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct UpsertProvider<'a> {
|
||||
pub name: &'a str,
|
||||
pub display_name: &'a str,
|
||||
pub auth_url: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub client_secret: &'a str,
|
||||
pub redirect_uri: &'a str,
|
||||
pub extra_params: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertProvider<'_>) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO oauth_providers
|
||||
(name, display_name, auth_url, token_url, client_id, client_secret, redirect_uri, extra_params)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
auth_url = excluded.auth_url,
|
||||
token_url = excluded.token_url,
|
||||
client_id = excluded.client_id,
|
||||
-- Keep the stored secret when the form submits an empty one: the admin
|
||||
-- editing a provider's URLs should not have to re-paste the secret,
|
||||
-- which the list view never gave back to the browser.
|
||||
client_secret = CASE WHEN excluded.client_secret = ''
|
||||
THEN oauth_providers.client_secret
|
||||
ELSE excluded.client_secret END,
|
||||
redirect_uri = excluded.redirect_uri,
|
||||
extra_params = excluded.extra_params,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(p.name)
|
||||
.bind(p.display_name)
|
||||
.bind(p.auth_url)
|
||||
.bind(p.token_url)
|
||||
.bind(p.client_id)
|
||||
.bind(p.client_secret)
|
||||
.bind(p.redirect_uri)
|
||||
.bind(p.extra_params)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, name: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM oauth_providers WHERE name = ?")
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user