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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,243 changes: 1,205 additions & 38 deletions rsworkspace/Cargo.lock

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions rsworkspace/crates/mcp/mcp-postgres/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[package]
name = "mcp-postgres"
version = "0.0.1"
edition = "2024"
license = "Apache-2.0"

[lints]
workspace = true

[dependencies]
authzed = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
http = { workspace = true }
jsonwebtoken = { workspace = true }
futures-util = { workspace = true }
pg_query = "=6.2.1"
rmcp = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true, features = ["tls-rustls-aws-lc-rs"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] }
trogon-std = { workspace = true }
toml = { workspace = true }
tower-http = { workspace = true, features = ["cors"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["json"] }
tonic = { workspace = true, features = ["tls-aws-lc", "tls-native-roots"] }
url = { workspace = true }

[dev-dependencies]
base64 = { workspace = true }
rmcp = { workspace = true, features = ["transport-streamable-http-client-reqwest"] }
rcgen = "=0.14.8"
rsa = { version = "=0.9.10", features = ["pem"] }
rustls = { workspace = true }
tempfile = { workspace = true }
testcontainers-modules = { workspace = true, features = ["postgres"] }
28 changes: 28 additions & 0 deletions rsworkspace/crates/mcp/mcp-postgres/examples/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
spicedb_endpoint = "http://127.0.0.1:50051"
spicedb_token_env = "MCP_POSTGRES_SPICEDB_TOKEN"

[[connections]]
name = "analytics-readonly"
id = "analytics-local-v1"
dsn_env = "MCP_POSTGRES_ANALYTICS_DSN"

[http]
bind = "0.0.0.0:8080"
resource_url = "https://sql.example.com/mcp"
allowed_origins = ["https://admin.example.com"]

[[authentication.issuers]]
issuer = "https://identity.example.com"
jwks_uri = "https://identity.example.com/.well-known/jwks.json"
audience = "https://sql.example.com/mcp"
user_claim = "sub"
client_claim = "client_id"
scope_claim = "scope"
required_scopes = ["sql:query"]
delegation = "optional"

[authentication.issuers.users]
"external-user-id" = "example-user"

[authentication.issuers.clients]
"external-client-id" = "example-client"
20 changes: 20 additions & 0 deletions rsworkspace/crates/mcp/mcp-postgres/sql/schema.zed
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
definition sql_user {}
definition sql_client {}

definition sql_connection {
relation reader: sql_user | sql_client
relation forbidden: sql_user | sql_user:* | sql_client | sql_client:*
permission query = reader - forbidden
}

definition sql_table {
relation reader: sql_user | sql_client
relation forbidden: sql_user | sql_user:* | sql_client | sql_client:*
permission query = reader - forbidden
}

definition sql_column {
relation reader: sql_user | sql_client
relation forbidden: sql_user | sql_user:* | sql_client | sql_client:*
permission query = reader - forbidden
}
11 changes: 11 additions & 0 deletions rsworkspace/crates/mcp/mcp-postgres/src/auth_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("invalid authentication configuration")]
Configuration,
#[error("authentication required")]
Unauthenticated,
#[error("required scope is missing")]
InsufficientScope,
#[error("authentication service unavailable")]
Unavailable,
}
123 changes: 123 additions & 0 deletions rsworkspace/crates/mcp/mcp-postgres/src/authentication_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use crate::{AuthError, ObjectId};
use serde::Deserialize;
use std::collections::BTreeMap;
use url::Url;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticationConfigInput {
pub issuers: Vec<IssuerInput>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IssuerInput {
pub issuer: String,
pub jwks_uri: String,
pub audience: String,
#[serde(default = "default_user_claim")]
pub user_claim: String,
#[serde(default = "default_client_claim")]
pub client_claim: String,
#[serde(default = "default_scope_claim")]
pub scope_claim: String,
#[serde(default)]
pub required_scopes: Vec<String>,
pub users: BTreeMap<String, String>,
pub clients: BTreeMap<String, String>,
#[serde(default)]
pub delegation: DelegationPolicy,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DelegationPolicy {
#[default]
Optional,
Required,
Forbidden,
}

pub(crate) struct Issuer {
pub(crate) issuer: String,
pub(crate) jwks_uri: Url,
pub(crate) audience: String,
pub(crate) user_claim: String,
pub(crate) client_claim: String,
pub(crate) scope_claim: String,
pub(crate) required_scopes: Vec<String>,
pub(crate) users: BTreeMap<String, ObjectId>,
pub(crate) clients: BTreeMap<String, ObjectId>,
pub(crate) delegation: DelegationPolicy,
}

impl TryFrom<IssuerInput> for Issuer {
type Error = AuthError;

fn try_from(input: IssuerInput) -> Result<Self, Self::Error> {
let issuer_url = endpoint(&input.issuer)?;
let jwks_uri = endpoint(&input.jwks_uri)?;
if issuer_url.query().is_some()
|| input.audience.is_empty()
|| [&input.user_claim, &input.client_claim, &input.scope_claim]
.iter()
.any(|claim| claim.is_empty() || claim.len() > 128)
|| input
.required_scopes
.iter()
.any(|scope| scope.is_empty() || scope.split_whitespace().count() != 1)
|| input.users.is_empty()
|| input.clients.is_empty()
{
return Err(AuthError::Configuration);
}
Ok(Self {
issuer: input.issuer,
jwks_uri,
audience: input.audience,
user_claim: input.user_claim,
client_claim: input.client_claim,
scope_claim: input.scope_claim,
required_scopes: input.required_scopes,
users: mappings(input.users)?,
clients: mappings(input.clients)?,
delegation: input.delegation,
})
}
}

fn mappings(input: BTreeMap<String, String>) -> Result<BTreeMap<String, ObjectId>, AuthError> {
input
.into_iter()
.map(|(external, local)| {
if external.is_empty() {
return Err(AuthError::Configuration);
}
Ok((external, ObjectId::new(local).map_err(|_| AuthError::Configuration)?))
})
.collect()
}

fn endpoint(input: &str) -> Result<Url, AuthError> {
let url = Url::parse(input).map_err(|_| AuthError::Configuration)?;
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
if !(url.scheme() == "https" || (url.scheme() == "http" && loopback))
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(AuthError::Configuration);
}
Ok(url)
}

fn default_user_claim() -> String {
"sub".to_owned()
}
fn default_client_claim() -> String {
"client_id".to_owned()
}
fn default_scope_claim() -> String {
"scope".to_owned()
}
Loading
Loading