feat(aiagent): handle oauth for mcp tools (#7564)

* feat(aiagent): handle oauth for mcp tools

* cleaning

* cleaning

* move oauth2

* cleaning

* cimd

* wrap path

* fix frontend

* fix

* refresh resources

* cleaning

* update ee ref

* update sqlx

* cleaning

* update

* cleaning

* update ref

* cleaning

* cleaning

* update ref
This commit is contained in:
centdix
2026-01-14 13:06:40 +01:00
committed by GitHub
parent f72c16456f
commit 5c08abe141
23 changed files with 828 additions and 50 deletions
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n variable.path,\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "account_id",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "is_expired: bool",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true,
null
]
},
"hash": "5840477599957b528e10b638e0616b6fb9d04b78271b827c21a399a1474627d7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8) RETURNING id",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
"describe": {
"columns": [
{
@@ -18,12 +18,13 @@
"Varchar",
"Varchar",
"Varchar",
"Varchar"
"Varchar",
"Text"
]
},
"nullable": [
false
]
},
"hash": "bbc28b92ae8ec3d120a8976be7d3966282fe6543e0eb957fc10864dbf58de58f"
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n variable.account as account_id,\n (now() > account.expires_at) as \"is_expired: bool\"\n FROM variable\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n WHERE variable.path = $1 AND variable.workspace_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "account_id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "is_expired: bool",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
null
]
},
"hash": "b4fc94adfe55bb87d2c4b6b45ed2eb5ac25e84f3619a0b403a2d619a0eb51432"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "client",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "refresh_token",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "grant_type",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "cc_client_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "cc_client_secret",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "cc_token_url",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "mcp_server_url",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
}
+3
View File
@@ -10944,6 +10944,7 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"oauth2",
"pastey",
"pin-project-lite",
"rand 0.9.0",
@@ -10959,6 +10960,7 @@ dependencies = [
"tokio-util",
"tower-service",
"tracing",
"url",
"uuid",
]
@@ -15561,6 +15563,7 @@ name = "windmill-mcp"
version = "1.605.0"
dependencies = [
"anyhow",
"oauth2",
"reqwest 0.12.28",
"rmcp",
"serde",
+1 -1
View File
@@ -1 +1 @@
27dc729e842c200c6b097dc69d99b6a0927465f2
6262e373ede45b5b8d45505a63ff73968c04336a
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_account_mcp_server_url;
ALTER TABLE account DROP COLUMN IF EXISTS mcp_server_url;
DROP TABLE IF EXISTS mcp_oauth_client;
@@ -0,0 +1,13 @@
CREATE TABLE mcp_oauth_client (
mcp_server_url TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
client_secret TEXT,
client_secret_expires_at TIMESTAMP,
token_endpoint TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_mcp_oauth_client_expires ON mcp_oauth_client(client_secret_expires_at);
ALTER TABLE account ADD COLUMN mcp_server_url TEXT;
CREATE INDEX idx_account_mcp_server_url ON account(mcp_server_url) WHERE mcp_server_url IS NOT NULL;
+2 -1
View File
@@ -36,7 +36,7 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws
deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
cloud = ["windmill-common/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
python = []
[dependencies]
@@ -158,5 +158,6 @@ tonic = { workspace = true, optional = true }
deno_error = { workspace = true, optional = true }
deno_core = { workspace = true, optional = true }
backon = {workspace = true, optional = true}
[build-dependencies]
deno_core = { workspace = true, optional = true }
+95
View File
@@ -4224,6 +4224,9 @@ paths:
cc_token_url:
type: string
description: "OAuth token URL override for resource-level authentication (client_credentials flow only)"
mcp_server_url:
type: string
description: "MCP server URL for MCP OAuth token refresh"
required:
- refresh_token
- expires_in
@@ -15842,6 +15845,98 @@ paths:
items:
$ref: "#/components/schemas/EndpointTool"
/mcp/oauth/discover:
post:
summary: discover MCP server OAuth metadata
operationId: discoverMcpOAuth
tags:
- mcp_oauth
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- mcp_server_url
properties:
mcp_server_url:
type: string
description: URL of the MCP server to discover OAuth metadata from
responses:
"200":
description: OAuth metadata from MCP server
content:
application/json:
schema:
type: object
properties:
scopes_supported:
type: array
items:
type: string
authorization_endpoint:
type: string
token_endpoint:
type: string
registration_endpoint:
type: string
supports_dynamic_registration:
type: boolean
/mcp/oauth/start:
get:
summary: start MCP OAuth popup flow
description: Opens in a popup, discovers OAuth metadata, registers client, and redirects to OAuth provider
operationId: startMcpOAuthPopup
tags:
- mcp_oauth
parameters:
- name: mcp_server_url
in: query
required: true
schema:
type: string
description: URL of the MCP server to connect to
- name: scopes
in: query
required: false
schema:
type: string
description: Comma-separated list of OAuth scopes to request
responses:
"302":
description: Redirect to OAuth provider authorization URL
/mcp/oauth/callback:
get:
security: []
summary: MCP OAuth callback
description: Handles OAuth callback, exchanges code for tokens, returns HTML that posts message to opener
operationId: mcpOAuthCallback
tags:
- mcp_oauth
parameters:
- name: code
in: query
required: true
schema:
type: string
description: OAuth authorization code
- name: state
in: query
required: true
schema:
type: string
description: CSRF state token
responses:
"200":
description: HTML page with JavaScript that posts tokens to opener window and closes
content:
text/html:
schema:
type: string
components:
securitySchemes:
bearerAuth:
+13
View File
@@ -191,6 +191,10 @@ mod workspaces_oss;
#[cfg(feature = "mcp")]
mod mcp;
#[cfg(all(feature = "mcp", feature = "private"))]
mod mcp_oauth_ee;
#[cfg(feature = "mcp")]
mod mcp_oauth_oss;
pub use apps::EditApp;
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
@@ -668,6 +672,15 @@ pub async fn run_server(
#[cfg(not(feature = "oauth2"))]
Router::new()
})
.nest("/mcp/oauth", {
#[cfg(feature = "mcp")]
{
mcp_oauth_oss::global_service()
}
#[cfg(not(feature = "mcp"))]
Router::new()
})
.nest("/r", {
#[cfg(feature = "http_trigger")]
{
+115
View File
@@ -0,0 +1,115 @@
// Re-export from EE when private feature is enabled
#[cfg(feature = "private")]
pub use crate::mcp_oauth_ee::*;
// OSS stub implementations when private feature is not enabled
#[cfg(not(feature = "private"))]
mod oss_impl {
use axum::{
extract::Query,
response::{Html, Redirect},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use windmill_common::error::{self, JsonResult};
/// Global routes for MCP OAuth (OSS stub - returns errors)
pub fn global_service() -> Router {
Router::new()
.route("/discover", post(discover_mcp_oauth))
.route("/start", get(start_mcp_oauth))
.route("/callback", get(mcp_oauth_callback))
.route("/client-metadata.json", get(get_client_metadata))
}
#[derive(Serialize)]
pub struct ClientMetadata {
pub client_name: &'static str,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<&'static str>,
pub response_types: Vec<&'static str>,
pub token_endpoint_auth_method: &'static str,
}
pub async fn get_client_metadata() -> Json<ClientMetadata> {
Json(ClientMetadata {
client_name: "Windmill",
redirect_uris: vec![],
grant_types: vec!["authorization_code", "refresh_token"],
response_types: vec!["code"],
token_endpoint_auth_method: "none",
})
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct DiscoverRequest {
pub mcp_server_url: String,
}
#[derive(Serialize)]
pub struct DiscoverResponse {
pub scopes_supported: Option<Vec<String>>,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub registration_endpoint: Option<String>,
pub supports_dynamic_registration: bool,
}
pub async fn discover_mcp_oauth(
Json(_req): Json<DiscoverRequest>,
) -> JsonResult<DiscoverResponse> {
Err(error::Error::BadRequest(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct StartPopupParams {
pub mcp_server_url: String,
#[serde(default)]
pub scopes: Option<String>,
}
pub async fn start_mcp_oauth(
Query(_params): Query<StartPopupParams>,
) -> Result<Redirect, error::Error> {
Err(error::Error::BadRequest(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct CallbackParams {
pub code: String,
pub state: String,
}
pub async fn mcp_oauth_callback(
Query(_params): Query<CallbackParams>,
) -> Result<Html<String>, error::Error> {
let html = r#"<!DOCTYPE html>
<html>
<head><title>MCP OAuth Error</title></head>
<body>
<script>
if (window.opener) {
window.opener.postMessage({
type: 'MCP_ERROR',
error: "Not implemented in Windmill's Open Source repository"
}, window.location.origin);
}
window.close();
</script>
<p>Not implemented in Windmill's Open Source repository</p>
</body>
</html>"#;
Ok(Html(html.to_string()))
}
}
#[cfg(not(feature = "private"))]
pub use oss_impl::*;
@@ -68,6 +68,7 @@ fn is_public_route_whitelisted(path: &str) -> bool {
"/api/oauth/login/*",
"/api/oauth/connect/*",
"/oauth/callback/*",
"/api/mcp/oauth/callback",
"/user/login_callback/*",
"/api/workspaces/users",
"/api/users/whoami",
+48 -4
View File
@@ -1414,7 +1414,7 @@ async fn get_mcp_tools(
let path = path.to_path();
check_scopes(&authed, || format!("resources:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let mut tx = user_db.clone().begin(&authed).await?;
// Fetch the MCP resource from database
let resource_value_o = sqlx::query_scalar!(
@@ -1435,9 +1435,53 @@ async fn get_mcp_tools(
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?;
// Parse MCP resource
let mcp_resource =
serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
let mcp_resource = serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
// Check if token needs refresh before creating MCP client
#[cfg(feature = "oauth2")]
{
tracing::info!("Checking if token needs refresh before creating MCP client");
if let Some(ref token_path) = mcp_resource.token {
let token_var_path = token_path.trim_start_matches("$var:");
// Query to check if token is expired
let token_info = sqlx::query!(
r#"
SELECT
variable.account as account_id,
(now() > account.expires_at) as "is_expired: bool"
FROM variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
WHERE variable.path = $1 AND variable.workspace_id = $2
"#,
token_var_path,
&w_id
)
.fetch_optional(&db)
.await?;
if let Some(info) = token_info {
if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) {
let refresh_tx = user_db.begin(&authed).await?;
if let Err(e) = crate::oauth2_oss::_refresh_token(
refresh_tx,
token_var_path,
&w_id,
account_id,
&db,
)
.await
{
tracing::warn!(
"Failed to refresh token for MCP resource: {}. Proceeding with possibly expired token.",
e
);
}
}
}
}
}
// Create MCP client connection
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
+2
View File
@@ -11,8 +11,10 @@ path = "src/lib.rs"
[features]
default = []
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker"]
auth = ["rmcp/auth", "dep:oauth2"]
[dependencies]
oauth2 = { version = "5.0", optional = true }
windmill-common = { workspace = true, default-features = false }
anyhow.workspace = true
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
+14
View File
@@ -47,6 +47,20 @@ pub mod server {
pub use rmcp::ErrorData;
}
// Re-export rmcp auth types when auth feature is enabled
#[cfg(feature = "auth")]
pub mod oauth {
//! Re-exports of rmcp auth and oauth2 types for MCP OAuth implementations
pub use rmcp::transport::auth::AuthorizationManager;
// Re-export oauth2 types needed for MCP OAuth flow
pub use oauth2::{
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
RedirectUrl, Scope, TokenUrl,
};
}
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
+89
View File
@@ -451,6 +451,80 @@ fn apply_tool_filters(
tools
}
/// Check if a token variable is expired and refresh it if needed via API call
#[cfg(feature = "mcp")]
async fn refresh_token_if_expired(
db: &DB,
workspace_id: &str,
token_path: &str,
auth_token: &str,
) -> Result<(), Error> {
// Query variable with account join to check expiration
let token_info = sqlx::query!(
r#"
SELECT
variable.path,
variable.account as account_id,
(now() > account.expires_at) as "is_expired: bool"
FROM variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2
WHERE variable.path = $1 AND variable.workspace_id = $2
"#,
token_path,
workspace_id
)
.fetch_optional(db)
.await?;
let Some(token_info) = token_info else {
return Ok(());
};
let Some(account_id) = token_info.account_id else {
return Ok(());
};
if !token_info.is_expired.unwrap_or(false) {
return Ok(());
}
tracing::debug!(
"Token variable {} is expired, triggering refresh",
token_path
);
// Call the API refresh endpoint
let base_url = windmill_common::BASE_URL.read().await.clone();
let refresh_url = format!(
"{}/api/w/{}/oauth/refresh_token/{}",
base_url, workspace_id, account_id
);
#[derive(serde::Serialize)]
struct RefreshRequest {
path: String,
}
let response = windmill_common::utils::HTTP_CLIENT
.post(&refresh_url)
.header("Authorization", format!("Bearer {}", auth_token))
.json(&RefreshRequest { path: token_path.to_string() })
.send()
.await
.map_err(|e| {
Error::internal_err(format!("Failed to call token refresh endpoint: {}", e))
})?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::internal_err(format!(
"Token refresh failed: {}",
error_text
)));
}
Ok(())
}
/// Load tools from MCP servers and return both the clients and tools
/// Returns a map of resource name -> client, and a vector of tools
#[cfg(feature = "mcp")]
@@ -458,6 +532,7 @@ pub async fn load_mcp_tools(
db: &DB,
workspace_id: &str,
mcp_configs: Vec<McpResourceConfig>,
auth_token: &str,
) -> Result<(HashMap<String, Arc<McpClient>>, Vec<Tool>), Error> {
let mut all_mcp_tools = Vec::new();
let mut mcp_clients = HashMap::new();
@@ -484,6 +559,19 @@ pub async fn load_mcp_tools(
let resource_name = mcp_resource.name.clone();
// Check if token needs refresh before creating MCP client
if let Some(ref token_path) = mcp_resource.token {
let token_var_path = token_path.trim_start_matches("$var:");
if let Err(e) =
refresh_token_if_expired(db, workspace_id, token_var_path, auth_token).await
{
tracing::warn!(
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
resource_name, e
);
}
}
// Create new MCP client for this execution
tracing::debug!("Creating fresh MCP client for {}", resource_name);
let client = McpClient::from_resource(mcp_resource, db, workspace_id)
@@ -561,6 +649,7 @@ pub async fn load_mcp_tools<T>(
_db: &DB,
_workspace_id: &str,
_mcp_configs: Vec<McpResourceConfig>,
_auth_token: &str,
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
Ok((HashMap::new(), Vec::new()))
}
+1 -1
View File
@@ -315,7 +315,7 @@ pub async fn handle_ai_agent_job(
let mut tools = tools;
let mcp_clients = if !mcp_configs.is_empty() {
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs).await?;
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs, &client.token).await?;
tools.extend(mcp_tools);
clients
} else {
+2 -2
View File
@@ -394,7 +394,7 @@
</script>
<div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-0 mb-1">
<div class="flex flex-col flex-wrap sm:flex-row sm:items-center gap-2 pb-0 mb-1">
{#if meta != undefined}
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if !hideUser}
@@ -462,7 +462,7 @@
{/if}
</div>
<div class="text-sm text-secondary">/</div>
<label class="block grow w-full max-w-md">
<label class="block grow min-w-32 max-w-md">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
bind:this={inputP}
@@ -89,6 +89,10 @@
appConnect?.open?.(resourceType)
}
export async function refreshResources() {
await loadResources(resourceType)
}
let loading = $state(true)
async function loadResources(resourceType: string | undefined) {
loading = true
@@ -46,7 +46,7 @@
/>
{:else if isMcpTool(tool)}
<!-- MCP tool - use McpToolEditor -->
<McpToolEditor bind:tool={tool as McpTool} {noEditor} />
<McpToolEditor bind:tool={tool as McpTool} />
{:else if isWebsearchTool(tool)}
<WebsearchToolDisplay />
{/if}
@@ -0,0 +1,243 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import {
McpOauthService,
OauthService,
ResourceService,
VariableService,
type DiscoverMcpOauthResponse
} from '$lib/gen'
import { Button } from '$lib/components/common'
import Label from '$lib/components/Label.svelte'
import Path from '$lib/components/Path.svelte'
import { sendUserToast } from '$lib/toast'
import { sameTopDomainOrigin } from '$lib/cookies'
import { onDestroy } from 'svelte'
interface Props {
onConnected: (resourcePath: string, resourceName: string) => void
onCancel: () => void
}
let { onConnected, onCancel }: Props = $props()
let serverUrl = $state('')
let discoveryResult = $state<DiscoverMcpOauthResponse | null>(null)
let selectedScopes = $state<string[]>([])
let resourceName = $state('')
let resourcePath = $state('')
let pathError = $state('')
let status = $state<'idle' | 'discovering' | 'discovered' | 'connecting'>('idle')
let error = $state<string | null>(null)
async function discoverOAuth() {
status = 'discovering'
error = null
try {
discoveryResult = await McpOauthService.discoverMcpOauth({
requestBody: { mcp_server_url: serverUrl }
})
selectedScopes = discoveryResult?.scopes_supported ?? []
try {
const urlObj = new URL(serverUrl)
resourceName = urlObj.hostname.replace(/\./g, '_')
} catch {
resourceName = 'mcp_server'
}
status = 'discovered'
} catch (e) {
console.error('Error discovering OAuth settings', e)
const errorMessage = e.body?.message || e.body || e.message || 'Unknown error'
error = `Failed to discover OAuth settings: ${errorMessage}`
status = 'idle'
}
}
function startOAuth() {
const url = new URL(`/api/mcp/oauth/start`, window.location.origin)
url.searchParams.set('mcp_server_url', serverUrl)
url.searchParams.set('scopes', selectedScopes.join(','))
const popup = window.open(url.toString(), '_blank', 'popup=true')
if (!popup) {
error = 'Popup blocked. Please allow popups for this site.'
return
}
window.addEventListener('message', handleOAuthMessage)
window.addEventListener('storage', handleStorageEvent)
status = 'connecting'
}
function handleOAuthMessage(event: MessageEvent) {
if (!sameTopDomainOrigin(event.origin, window.location.origin)) return
if (event.data.type === 'MCP_CONNECTED') {
cleanup()
createMcpResource(event.data)
} else if (event.data.type === 'MCP_ERROR') {
cleanup()
error = event.data.error
status = 'discovered'
}
}
function handleStorageEvent(event: StorageEvent) {
if (event.key === 'mcp-oauth-callback') {
cleanup()
try {
const data = JSON.parse(event.newValue || '{}')
localStorage.removeItem('mcp-oauth-callback')
if (data.type === 'MCP_CONNECTED') {
createMcpResource(data)
}
} catch (e) {
console.error('Error parsing MCP OAuth callback', e)
}
}
}
function cleanup() {
window.removeEventListener('message', handleOAuthMessage)
window.removeEventListener('storage', handleStorageEvent)
}
async function createMcpResource(data: {
access_token: string
refresh_token?: string
expires_in?: number
mcp_server_url: string
}) {
try {
let accountId: number | undefined
if (data.expires_in && data.refresh_token) {
const accountIdStr = await OauthService.createAccount({
workspace: $workspaceStore!,
requestBody: {
refresh_token: data.refresh_token,
expires_in: data.expires_in,
client: 'mcp',
mcp_server_url: data.mcp_server_url
}
})
accountId = Number(accountIdStr)
}
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path: resourcePath,
value: data.access_token,
is_secret: true,
is_oauth: true,
account: accountId,
description: `MCP OAuth token for ${data.mcp_server_url}`
}
})
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: 'mcp',
path: resourcePath,
value: {
name: resourceName,
url: data.mcp_server_url,
token: `$var:${resourcePath}`
},
description: `MCP server connected via OAuth`
}
})
sendUserToast('Connected to MCP server')
onConnected(resourcePath, resourceName)
} catch (e: any) {
error = e.body?.message || e.message || 'Failed to create resource'
status = 'discovered'
}
}
onDestroy(cleanup)
</script>
<div class="border rounded p-4 bg-surface-secondary flex flex-col gap-4">
<div class="flex justify-between items-center">
<span class="font-semibold text-sm">Connect MCP Server with OAuth</span>
<Button size="xs" color="light" onClick={onCancel}>Cancel</Button>
</div>
<Label label="MCP Server URL">
<input
type="url"
bind:value={serverUrl}
placeholder="https://mcp.example.com"
class="text-sm w-full"
disabled={status === 'connecting'}
/>
</Label>
{#if status === 'idle'}
<Button size="sm" onClick={discoverOAuth} disabled={!serverUrl}>Discover OAuth Settings</Button>
{:else if status === 'discovering'}
<div class="text-sm text-secondary">Discovering OAuth settings...</div>
{:else if status === 'discovered' && discoveryResult}
<div class="text-xs text-green-600 dark:text-green-400">
&#10003; OAuth supported
{#if discoveryResult.supports_dynamic_registration}
(Dynamic Client Registration available)
{/if}
</div>
{#if discoveryResult.scopes_supported && discoveryResult.scopes_supported.length > 0}
<Label label="Select Scopes">
<div class="flex flex-col flex-wrap gap-2">
{#each discoveryResult.scopes_supported as scope}
<label class="flex flex-row items-center gap-2 text-xs cursor-pointer">
<input
type="checkbox"
checked={selectedScopes.includes(scope)}
onchange={(e) => {
const target = e.target as HTMLInputElement
if (target.checked) {
selectedScopes = [...selectedScopes, scope]
} else {
selectedScopes = selectedScopes.filter((s) => s !== scope)
}
}}
class="!w-4 !h-4"
/>
{scope}
</label>
{/each}
</div>
</Label>
{/if}
<Label label="Resource Name">
<input
type="text"
bind:value={resourceName}
placeholder="my-mcp-server"
class="text-sm w-full"
/>
</Label>
<Path
bind:path={resourcePath}
bind:error={pathError}
initialPath=""
namePlaceholder={resourceName}
kind="resource"
/>
<Button size="sm" onClick={startOAuth} disabled={!resourcePath || pathError !== ''}>
Connect with OAuth
</Button>
{:else if status === 'connecting'}
<div class="text-sm text-secondary">Complete authentication in popup window...</div>
{/if}
{#if error}
<div class="text-xs text-red-600 dark:text-red-400">{error}</div>
{/if}
</div>
@@ -12,7 +12,7 @@
{
initial: { workspace: get(workspaceStore), path: undefined, refreshCount: 0 },
invalidateMs: 1000 * 60
} // Cache for 60 seconds
}
)
</script>
@@ -28,15 +28,17 @@
import { usePromise } from '$lib/svelte5Utils.svelte'
import { untrack } from 'svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import McpOAuthConnect from './McpOAuthConnect.svelte'
interface Props {
tool: McpTool
noEditor: boolean
}
let { tool = $bindable() }: Props = $props()
let showOAuthForm = $state(false)
let refreshCount = $state(0)
let resourcePicker: ResourcePicker | undefined = $state()
let tools = usePromise(
async () =>
@@ -48,18 +50,16 @@
{ loadInit: false, clearValueOnRefresh: false }
)
// Options for the multiselect
let toolOptions = $derived(safeSelectItems((tools.value ?? []).map((t) => t.name)))
let resourcePath = $derived(tool.value.resource_path)
let error = $derived(tools.error?.body?.message || tools.error?.message)
// Watch for resource_path changes and refresh tools
$effect(() => {
// Track reactive dependencies
tool.value.resource_path
resourcePath
$workspaceStore
refreshCount
// Trigger refresh when resource_path or workspace changes
untrack(() => {
if (tool.value.resource_path?.length > 0) {
if (resourcePath?.length > 0) {
tools.refresh()
}
})
@@ -75,14 +75,20 @@
})
$effect(() => {
if (tool.value.resource_path?.length > 0 && tool.summary?.length === 0) {
if (resourcePath?.length > 0 && tool.summary?.length === 0) {
tool.summary = `MCP: ${tool.value.resource_path}`
}
})
async function handleOAuthConnected(resourcePath: string, resourceName: string) {
await resourcePicker?.refreshResources()
tool.value.resource_path = resourcePath
tool.summary = `MCP: ${resourceName}`
showOAuthForm = false
}
</script>
<div class="flex flex-col gap-4 p-4">
<!-- Explanatory Section -->
<Alert type="info" title="MCP Client Configuration">
{#snippet children()}
<p class="mb-2 text-sm">
@@ -97,15 +103,26 @@
{/snippet}
</Alert>
<!-- Resource Path Section -->
<div class="w-full">
<Label label="MCP Resource">
<ResourcePicker resourceType="mcp" bind:value={tool.value.resource_path} />
<ResourcePicker bind:this={resourcePicker} resourceType="mcp" bind:value={tool.value.resource_path} />
</Label>
</div>
{#if tool.value.resource_path?.length > 0}
<!-- Summary Section -->
{#if !resourcePath}
{#if !showOAuthForm}
<Button size="xs" color="light" onClick={() => (showOAuthForm = true)}>
Connect with OAuth
</Button>
{:else}
<McpOAuthConnect
onConnected={handleOAuthConnected}
onCancel={() => (showOAuthForm = false)}
/>
{/if}
{/if}
{#if resourcePath?.length > 0}
<div class="w-full">
<Label label="Summary">
<input
@@ -117,13 +134,12 @@
</Label>
</div>
<!-- Available Tools Section -->
<Section label="Available Tools">
{#snippet action()}
<Button
size="xs"
color="light"
on:click={() => (refreshCount += 1)}
onClick={() => (refreshCount += 1)}
startIcon={{ icon: RefreshCw }}
disabled={tools.status === 'loading'}
>
@@ -131,39 +147,37 @@
</Button>
{/snippet}
<div class="w-full flex flex-col gap-2">
{#if tools.error}
<div class="text-xs text-red-600 p-2 border border-red-300 rounded bg-red-50">
{tools.error?.body?.message ||
tools.error?.message ||
'Failed to load tools from MCP server'}
</div>
{/if}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
{#if tools.status === 'loading'}
{#if error}
<div class="text-xs text-red-600 dark:text-red-400 mb-4"
>{`Failed to load tools from MCP server: ${error}`}</div
>
{:else if tools.status === 'loading'}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">Loading tools...</div>
{:else if (tools.value ?? []).length === 0}
</div>
{:else if (tools.value ?? []).length === 0 && !error}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">
{tools.error
? 'Failed to load tools. Please check the resource path and try again.'
: 'No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.'}
No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.
</div>
{:else}
</div>
{:else if (tools.value ?? []).length > 0}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="flex flex-col gap-1">
{#each tools.value ?? [] as tool}
{#each tools.value ?? [] as mcpTool}
<div class="text-xs">
<span class="font-semibold">{tool.name}</span>
{#if tool.description}
<span class="text-secondary">{tool.description}</span>
<span class="font-semibold">{mcpTool.name}</span>
{#if mcpTool.description}
<span class="text-secondary">{mcpTool.description}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</Section>
<!-- Tool Filtering Section -->
{#if tool.value.include_tools && tool.value.exclude_tools}
<Section label="Tool Filtering">
<div class="w-full flex flex-col gap-3">