Skip to content
This repository was archived by the owner on Sep 10, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# `ftl`

Fast tools for AI agents
Faster tools for AI agents

[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![WebAssembly](https://img.shields.io/badge/WebAssembly-compatible-purple.svg)](https://webassembly.org/)
Expand All @@ -15,7 +15,7 @@ Fast tools for AI agents

</div>

FTL is a framework for polyglot [Model Context Protocol](https://modelcontextprotocol.io) servers. It composes [WebAssembly components](https://component-model.bytecodealliance.org/design/why-component-model.html) via [Spin](https://github.com/spinframework/spin) to present a *just works* story for adding capabilities to AI agents with performant, sandboxed, edge-ready tools.
FTL is a framework to write and run polyglot [Model Context Protocol](https://modelcontextprotocol.io) servers on [WebAssembly components](https://component-model.bytecodealliance.org/design/why-component-model.html) via [Spin](https://github.com/spinframework/spin).

Tools authored in multiple [source languages](./sdk/README.md) can run simultaneously in a single MCP server process on any host compatible with Spin/[Wasmtime](https://github.com/bytecodealliance/wasmtime), including your development machine.

Expand Down
34 changes: 18 additions & 16 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,24 +170,28 @@ enum EngCommand {
#[arg(long, value_name = "KEY=VALUE")]
variable: Vec<String>,

/// Set authentication mode (public, ftl-account, user-only, custom)
#[arg(long, value_name = "MODE")]
auth_mode: Option<String>,

/// Allowed users for user-only mode (comma-separated)
#[arg(long, value_name = "USER_IDS", requires = "auth_mode")]
auth_users: Option<String>,

/// Custom auth provider (authkit, auth0, oidc)
#[arg(long, value_name = "PROVIDER", requires = "auth_mode")]
/// Set access control mode (public, private, custom)
/// Overrides `FTL_ACCESS_CONTROL` env var and ftl.toml `auth.access_control`
#[arg(
long = "access-control",
value_name = "public|private|custom",
help_heading = "Authentication"
)]
access_control: Option<String>,

/// Custom auth provider (authkit, auth0, oidc, static)
/// Overrides `FTL_AUTH_PROVIDER` env var and ftl.toml auth provider
#[arg(long, value_name = "PROVIDER", help_heading = "Authentication")]
auth_provider: Option<String>,

/// Custom auth issuer URL
#[arg(long, value_name = "URL", requires = "auth_provider")]
/// Overrides `FTL_AUTH_ISSUER` env var and ftl.toml auth issuer
#[arg(long, value_name = "URL", help_heading = "Authentication")]
auth_issuer: Option<String>,

/// Custom auth audience
#[arg(long, value_name = "AUDIENCE", requires = "auth_provider")]
/// Overrides `FTL_AUTH_AUDIENCE` env var and ftl.toml auth audience
#[arg(long, value_name = "AUDIENCE", help_heading = "Authentication")]
auth_audience: Option<String>,

/// Run without making any changes (preview what would be deployed)
Expand Down Expand Up @@ -570,17 +574,15 @@ async fn handle_eng_command(args: EngArgs) -> Result<()> {
}
EngCommand::Deploy {
variable,
auth_mode,
auth_users,
access_control,
auth_provider,
auth_issuer,
auth_audience,
dry_run,
} => {
let deploy_args = ftl_commands::deploy::DeployArgs {
variables: variable,
auth_mode,
auth_users,
access_control,
auth_provider,
auth_issuer,
auth_audience,
Expand Down
2 changes: 1 addition & 1 deletion components/mcp-authorizer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "ftl-mcp-authorizer"
authors.workspace = true
description = "MCP authorization component for FTL servers using AuthKit"
version = "0.0.10"
version = "0.0.12"
license.workspace = true
rust-version.workspace = true
edition.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions components/mcp-authorizer/spin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ mcp_oauth_userinfo_endpoint = { default = "" }
# Static provider settings
mcp_static_tokens = { default = "" }

# Tenant restriction (for private mode)
mcp_tenant_id = { default = "" }

[[trigger.http]]
route = "/..."
component = "mcp-authorizer"
Expand Down Expand Up @@ -62,6 +65,9 @@ mcp_oauth_userinfo_endpoint = "{{ mcp_oauth_userinfo_endpoint }}"
# Static provider settings
mcp_static_tokens = "{{ mcp_static_tokens }}"

# Tenant restriction (for private mode)
mcp_tenant_id = "{{ mcp_tenant_id }}"

# Test configuration
[component.mcp-authorizer.tool.spin-test]
source = "tests/target/wasm32-wasip1/release/tests.wasm"
Expand Down
3 changes: 3 additions & 0 deletions components/mcp-authorizer/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub struct Context {

/// Raw bearer token (for forwarding if needed)
pub raw_token: String,

/// Organization ID (optional)
pub org_id: Option<String>,
}

/// Extract bearer token from request
Expand Down
26 changes: 25 additions & 1 deletion components/mcp-authorizer/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub struct Config {

/// JWT provider configuration (always required)
pub provider: Provider,

/// Required tenant ID for private mode (optional)
pub tenant_id: Option<String>,
}

/// Provider type enumeration
Expand Down Expand Up @@ -79,6 +82,9 @@ pub struct StaticTokenInfo {

/// Optional expiration timestamp
pub expires_at: Option<i64>,

/// Organization ID (optional)
pub org_id: Option<String>,
}

/// OAuth 2.0 endpoint configuration
Expand All @@ -102,10 +108,16 @@ impl Config {
// Provider configuration is always required
let provider = Provider::load()?;

// Load tenant ID for private mode
let tenant_id = variables::get("mcp_tenant_id")
.ok()
.filter(|s| !s.is_empty());

Ok(Self {
gateway_url,
trace_header,
provider,
tenant_id,
})
}
}
Expand Down Expand Up @@ -223,7 +235,7 @@ impl Provider {
use std::collections::HashMap;

// Load static tokens from configuration
// Format: mcp_static_tokens = "token1:client1:user1:read,write;token2:client2:user2:admin"
// Format: mcp_static_tokens = "token1:client1:user1:read,write[:expires_at[:org_id]];token2:client2:user2:admin"
let tokens_config = variables::get("mcp_static_tokens")
.map_err(|_| anyhow::anyhow!("Missing mcp_static_tokens for static provider"))?;

Expand Down Expand Up @@ -262,13 +274,25 @@ impl Provider {
// Optional expiration timestamp as 5th part
let expires_at = parts.get(4).and_then(|s| s.parse::<i64>().ok());

// Optional org_id as 6th part (or 5th if no expiration)
let org_id = if expires_at.is_some() {
parts.get(5).map(|s| (*s).to_string())
} else {
// If 5th part is not a number, treat it as org_id
parts
.get(4)
.filter(|s| s.parse::<i64>().is_err())
.map(|s| (*s).to_string())
};

tokens.insert(
token,
StaticTokenInfo {
client_id,
sub,
scopes,
expires_at,
org_id,
},
);
}
Expand Down
124 changes: 79 additions & 45 deletions components/mcp-authorizer/src/forwarding.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Request forwarding to the MCP gateway

use spin_sdk::http::{Request, Response};
use spin_sdk::http::{Headers, Request, Response};

use crate::auth::Context as AuthContext;
use crate::config::Config;
Expand All @@ -15,47 +15,8 @@ pub async fn forward_to_gateway(
// Parse gateway URL to set the scheme and authority
let gateway_url = url::Url::parse(&config.gateway_url)?;

// Create headers first
let headers = spin_sdk::http::Headers::new();

// Copy request headers
for (name, value) in req.headers() {
headers.append(&name.to_string(), &value.as_bytes().to_vec())?;
}

// Add authentication context headers
headers.append(
&"x-auth-client-id".to_string(),
&auth_context.client_id.as_bytes().to_vec(),
)?;
headers.append(
&"x-auth-user-id".to_string(),
&auth_context.user_id.as_bytes().to_vec(),
)?;
headers.append(
&"x-auth-issuer".to_string(),
&auth_context.issuer.as_bytes().to_vec(),
)?;

if !auth_context.scopes.is_empty() {
headers.append(
&"x-auth-scopes".to_string(),
&auth_context.scopes.join(" ").as_bytes().to_vec(),
)?;
}

// Forward the original authorization header
headers.append(
&"authorization".to_string(),
&format!("Bearer {}", auth_context.raw_token)
.as_bytes()
.to_vec(),
)?;

// Add trace ID if present
if let Some(trace_id) = &trace_id {
headers.append(&config.trace_header, &trace_id.as_bytes().to_vec())?;
}
// Build headers with authentication context
let headers = build_forwarding_headers(&req, &auth_context, trace_id.as_ref(), config)?;

// Build outgoing request with the headers
let outgoing = spin_sdk::http::OutgoingRequest::new(headers);
Expand Down Expand Up @@ -126,7 +87,80 @@ pub async fn forward_to_gateway(
// Extract body
let body = incoming_response.into_body();

// Build the final response
// Build response with proper headers
Ok(build_gateway_response(
status,
headers_vec,
body,
trace_id,
&config.trace_header,
))
}

/// Build headers for forwarding request
fn build_forwarding_headers(
req: &Request,
auth_context: &AuthContext,
trace_id: Option<&String>,
config: &Config,
) -> anyhow::Result<Headers> {
let headers = Headers::new();

// Copy request headers
for (name, value) in req.headers() {
headers.append(&name.to_string(), &value.as_bytes().to_vec())?;
}

// Add authentication context headers
headers.append(
&"x-auth-client-id".to_string(),
&auth_context.client_id.as_bytes().to_vec(),
)?;
headers.append(
&"x-auth-user-id".to_string(),
&auth_context.user_id.as_bytes().to_vec(),
)?;
headers.append(
&"x-auth-issuer".to_string(),
&auth_context.issuer.as_bytes().to_vec(),
)?;

if !auth_context.scopes.is_empty() {
headers.append(
&"x-auth-scopes".to_string(),
&auth_context.scopes.join(" ").as_bytes().to_vec(),
)?;
}

// Add organization ID if present
if let Some(org_id) = &auth_context.org_id {
headers.append(&"x-auth-org-id".to_string(), &org_id.as_bytes().to_vec())?;
}

// Forward the original authorization header
headers.append(
&"authorization".to_string(),
&format!("Bearer {}", auth_context.raw_token)
.as_bytes()
.to_vec(),
)?;

// Add trace ID if present
if let Some(trace_id) = trace_id {
headers.append(&config.trace_header, &trace_id.as_bytes().to_vec())?;
}

Ok(headers)
}

/// Build gateway response with CORS headers
fn build_gateway_response(
status: u16,
headers_vec: Vec<(String, String)>,
body: Vec<u8>,
trace_id: Option<String>,
trace_header: &str,
) -> Response {
let mut binding = Response::builder();
let mut response_builder = binding.status(status);

Expand All @@ -149,9 +183,9 @@ pub async fn forward_to_gateway(

// Add trace ID if present
if let Some(trace_id) = trace_id {
response_builder = response_builder.header(&config.trace_header, trace_id);
response_builder = response_builder.header(trace_header, trace_id);
}

// Build the response with body
Ok(response_builder.body(body).build())
response_builder.body(body).build()
}
8 changes: 3 additions & 5 deletions components/mcp-authorizer/src/jwks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,15 @@ pub async fn fetch_jwks(jwks_uri: &str, store: &Store) -> Result<Jwks> {
}

// Fetch JWKS from URI
eprintln!("Fetching JWKS from: {jwks_uri}");
let request = spin_sdk::http::Request::builder()
.method(spin_sdk::http::Method::Get)
.uri(jwks_uri)
.header("Accept", "application/json")
.build();

let response: Response = spin_sdk::http::send(request).await.map_err(|e| {
eprintln!("Failed to fetch JWKS from {jwks_uri}: {e}");
AuthError::Internal(format!("Failed to fetch JWKS: {e}"))
})?;
let response: Response = spin_sdk::http::send(request)
.await
.map_err(|e| AuthError::Internal(format!("Failed to fetch JWKS: {e}")))?;

if *response.status() != 200 {
return Err(AuthError::Internal(format!(
Expand Down
Loading
Loading