From a36fde9cee8d499713109ccee0cba9b1ff1158ad Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Tue, 14 Jul 2026 13:29:22 +0200 Subject: [PATCH 1/3] feat: add MCP server subcommand for AI-assisted token retrieval Add `oidc mcp` subcommand that starts a stdio-based MCP server using the rmcp crate, exposing `list_clients` and `get_token` tools. This allows AI assistants like Claude Code to retrieve OIDC tokens for configured clients. The MCP functionality is behind a default-enabled `mcp` feature flag so it can be excluded when not needed. Also adds cargo-all-features to CI to verify all feature combinations compile. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 18 +++++ Cargo.lock | 128 ++++++++++++++++++++++++++++++++ Cargo.toml | 4 + README.md | 25 +++++++ src/cmd/mcp.rs | 152 ++++++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 6 ++ 6 files changed, 333 insertions(+) create mode 100644 src/cmd/mcp.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8240f48..2222e0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -96,11 +96,29 @@ jobs: - name: Test run: cargo +${{ matrix.rust }} test --workspace ${{ matrix.args }} -- --nocapture + features: + needs: fmt + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - uses: Swatinem/rust-cache@v2 + + - name: Install dependencies + run: sudo apt install -y libssl-dev + + - name: Install cargo-all-features + run: cargo install cargo-all-features + + - name: Check all feature combinations + run: cargo check-all-features + ci: runs-on: ubuntu-latest needs: - fmt - test + - features if: always() steps: - name: Success diff --git a/Cargo.lock b/Cargo.lock index fabbbea..cd08878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1029,6 +1040,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1036,6 +1062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1044,6 +1071,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -1062,8 +1117,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1875,6 +1935,8 @@ dependencies = [ "openssl", "pretty-hex", "reqwest", + "rmcp", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml", @@ -2053,6 +2115,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2460,6 +2528,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "rsa" version = "0.9.10" @@ -2582,12 +2685,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2677,6 +2794,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/Cargo.toml b/Cargo.toml index 5a32d9b..c8f4289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ openidconnect = { version = "4", features = ["accept-rfc3339-timestamps"] } oauth2 = "5" pretty-hex = "0.4.1" reqwest = "0.12" # keep aligned with openidconnect +rmcp = { version = "2", features = ["server", "transport-io", "macros"], optional = true } +schemars = { version = "1", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -48,6 +50,8 @@ url = "2" openssl = "0.10" # transient dependency, required for vendoring [features] +default = ["mcp"] +mcp = ["dep:rmcp", "dep:schemars"] vendored = [ "openssl/vendored" ] diff --git a/README.md b/README.md index 21e285b..c5b2d11 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,31 @@ This also works with `curl`: curl http://example.com/api -H $(oidc token -H my-client) ``` +## MCP Server + +`oidc-cli` includes a built-in [MCP](https://modelcontextprotocol.io/) server that lets AI assistants retrieve OIDC +tokens for configured clients. This is useful when you want AI-powered tools to make authenticated API calls on your +behalf. + +First, set up your OIDC clients as usual (see above). Then start the MCP server: + +```bash +oidc mcp +``` + +The server communicates over stdio and exposes two tools: + +* **`list_clients`** — lists all configured OIDC clients with their issuer URL and token status +* **`get_token`** — retrieves a valid token for a named client, automatically refreshing if expired + +### Claude Code + +To register the MCP server with [Claude Code](https://docs.anthropic.com/en/docs/claude-code): + +```bash +claude mcp add oidc -- oidc mcp +``` + ## More examples Create a public client from an initial refresh token. This can be useful if you have a frontend application, but no diff --git a/src/cmd/mcp.rs b/src/cmd/mcp.rs new file mode 100644 index 0000000..e0606b6 --- /dev/null +++ b/src/cmd/mcp.rs @@ -0,0 +1,152 @@ +use crate::{ + config::Config, + http::HttpOptions, + oidc::{TokenResult, get_token}, +}; +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::wrapper::Parameters, + model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::io::stdio, +}; +use std::path::PathBuf; + +/// Start an MCP (Model Context Protocol) server on stdio +#[derive(Debug, clap::Parser)] +#[command(rename_all_env = "SNAKE_CASE")] +pub struct Mcp { + #[arg(from_global)] + pub config: Option, + + #[command(flatten)] + pub http: HttpOptions, +} + +#[derive(Clone)] +struct OidcMcpServer { + config_path: Option, + http: HttpOptions, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct GetTokenParams { + /// Name of the configured OIDC client + name: String, + /// Type of token to retrieve: "access" (default), "id", or "refresh" + #[serde(default = "default_token_type")] + token_type: String, +} + +fn default_token_type() -> String { + "access".to_string() +} + +#[tool_router] +impl OidcMcpServer { + /// List all configured OIDC client names with their issuer URLs and token expiry status. + #[tool(description = "List all configured OIDC clients")] + async fn list_clients(&self) -> Result { + let config = Config::load(self.config_path.as_deref()).map_err(|e| { + rmcp::ErrorData::internal_error(format!("failed to load config: {e}"), None) + })?; + + let mut lines = Vec::new(); + for (name, client) in &config.clients { + let issuer = client.issuer_url.as_str(); + let status = match &client.state { + Some(state) => match state.expires { + Some(exp) if exp > time::OffsetDateTime::now_utc() => "valid".to_string(), + Some(exp) => format!("expired ({})", exp), + None => "unknown expiry".to_string(), + }, + None => "no token".to_string(), + }; + lines.push(format!("{name}: issuer={issuer}, status={status}")); + } + + if lines.is_empty() { + return Ok(CallToolResult::success(vec![ContentBlock::text( + "No clients configured. Use `oidc create` to add one.", + )])); + } + + Ok(CallToolResult::success(vec![ContentBlock::text( + lines.join("\n"), + )])) + } + + /// Retrieve a token for a configured OIDC client, refreshing if expired. + #[tool(description = "Get an OIDC token for a configured client")] + async fn get_token( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut config = Config::load(self.config_path.as_deref()).map_err(|e| { + rmcp::ErrorData::internal_error(format!("failed to load config: {e}"), None) + })?; + + let client = config.by_name_mut(¶ms.name).ok_or_else(|| { + rmcp::ErrorData::invalid_params(format!("unknown client '{}'", params.name), None) + })?; + + let token = get_token(client, &self.http).await.map_err(|e| { + rmcp::ErrorData::internal_error(format!("failed to get token: {e}"), None) + })?; + + let state = match token { + TokenResult::Refreshed(state) => { + client.state = Some(state.clone()); + config.store(self.config_path.as_deref()).map_err(|e| { + rmcp::ErrorData::internal_error(format!("failed to store config: {e}"), None) + })?; + state + } + TokenResult::Existing(state) => state, + }; + + let token_value = match params.token_type.as_str() { + "id" => state + .id_token + .ok_or_else(|| rmcp::ErrorData::invalid_params("ID token not available", None))?, + "refresh" => state.refresh_token.ok_or_else(|| { + rmcp::ErrorData::invalid_params("refresh token not available", None) + })?, + _ => state.access_token, + }; + + Ok(CallToolResult::success(vec![ContentBlock::text( + token_value, + )])) + } +} + +#[tool_handler] +impl ServerHandler for OidcMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(rmcp::model::Implementation::new( + "oidc-cli", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions( + "OIDC token provider. Use list_clients to see configured clients, \ + then get_token to retrieve a valid access token.", + ) + } +} + +impl Mcp { + /// Start the MCP server on stdio. + pub async fn run(self) -> anyhow::Result<()> { + let server = OidcMcpServer { + config_path: self.config.clone(), + http: self.http, + }; + + let service = server.serve(stdio()).await?; + service.waiting().await?; + + Ok(()) + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 465add9..a4be02c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -3,6 +3,8 @@ mod create; mod delete; mod inspect; mod list; +#[cfg(feature = "mcp")] +mod mcp; mod token; use std::process::ExitCode; @@ -16,6 +18,8 @@ pub enum Command { List(list::List), Inspect(inspect::Inspect), Completion(completion::GetCompletion), + #[cfg(feature = "mcp")] + Mcp(mcp::Mcp), } impl Command { @@ -27,6 +31,8 @@ impl Command { Self::List(cmd) => cmd.run().await, Self::Inspect(cmd) => cmd.run().await, Self::Completion(cmd) => cmd.run().await, + #[cfg(feature = "mcp")] + Self::Mcp(cmd) => cmd.run().await, } .map(|()| ExitCode::SUCCESS) } From 16de7aa4bdb8b58643a47fd040952b30eb6f6442 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Tue, 14 Jul 2026 13:30:48 +0200 Subject: [PATCH 2/3] chore: add .serena to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d81f12e..4c09fcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /.idea +/.serena From 1d3ea36f999dadc4c5fd5e46ce35dc2bfd25f1e3 Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Tue, 14 Jul 2026 14:03:07 +0200 Subject: [PATCH 3/3] feat: add file-system locking for concurrent config access Wrap all config read-modify-write cycles in Config::locked(), which acquires an advisory exclusive lock on a sidecar .lock file. This prevents race conditions when the MCP server and CLI commands run concurrently. Uses std::fs::File::lock() (stable since 1.89) with spawn_blocking and AsyncFnOnce closures. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 2 +- Cargo.toml | 4 +- src/cmd/create/confidential.rs | 73 ++++++++++--------- src/cmd/create/public.rs | 128 ++++++++++++++++----------------- src/cmd/delete.rs | 19 +++-- src/cmd/mcp.rs | 60 ++++++++-------- src/cmd/token.rs | 70 +++++++++--------- src/config.rs | 56 ++++++++++++--- 8 files changed, 220 insertions(+), 192 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2222e0a..48a38d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: rust: - stable - - "1.88" # MSRV + - "1.89" # MSRV os: - ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index c8f4289..7bac0fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ repository = "https://github.com/ctron/oidc-cli" categories = ["command-line-utilities", "authentication"] keywords = ["oidc", "cli"] readme = "README.md" -# based on comfy table, requiring more recent lang features -rust-version = "1.88" +# File::lock() requires 1.89 +rust-version = "1.89" [[bin]] name = "oidc" diff --git a/src/cmd/create/confidential.rs b/src/cmd/create/confidential.rs index f5d89c4..a970022 100644 --- a/src/cmd/create/confidential.rs +++ b/src/cmd/create/confidential.rs @@ -33,48 +33,47 @@ impl CreateConfidential { pub async fn run(self) -> anyhow::Result<()> { log::debug!("creating new client: {}", self.common.name); - let mut config = Config::load(self.config.as_deref())?; - - if !self.common.force && config.clients.contains_key(&self.common.name) { - bail!( - "A client named '{}' already exists. You need to delete it first or use --force", - self.common.name - ); - } - - let mut client = Client { - issuer_url: self.common.issuer, - scope: self.common.scope, - r#type: ClientType::Confidential { - client_id: self.client_id, - client_secret: self.client_secret, - }, - state: None, - }; - - if !self.common.skip_initial { - let token = get_token(&client, &self.http) - .await - .context("failed retrieving first token")?; - - let token = match token { - TokenResult::Refreshed(token) | TokenResult::Existing(token) => token, + Config::locked(self.config.as_deref(), async |config| { + if !self.common.force && config.clients.contains_key(&self.common.name) { + bail!( + "A client named '{}' already exists. You need to delete it first or use --force", + self.common.name + ); + } + + let mut client = Client { + issuer_url: self.common.issuer.clone(), + scope: self.common.scope.clone(), + r#type: ClientType::Confidential { + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }, + state: None, }; - log::info!("First token:"); - log::info!(" ID: {}", OrNone(&token.id_token)); - log::info!(" Access: {}", token.access_token); - log::info!(" Refresh: {}", OrNone(&token.refresh_token)); + if !self.common.skip_initial { + let token = get_token(&client, &self.http) + .await + .context("failed retrieving first token")?; + + let token = match token { + TokenResult::Refreshed(token) | TokenResult::Existing(token) => token, + }; - client.state = Some(token); - } + log::info!("First token:"); + log::info!(" ID: {}", OrNone(&token.id_token)); + log::info!(" Access: {}", token.access_token); + log::info!(" Refresh: {}", OrNone(&token.refresh_token)); - config - .clients - .insert(self.common.name.clone(), client.clone()); + client.state = Some(token); + } - config.store(self.config.as_deref())?; + config + .clients + .insert(self.common.name.clone(), client.clone()); - Ok(()) + Ok(()) + }) + .await } } diff --git a/src/cmd/create/public.rs b/src/cmd/create/public.rs index 59ef16c..5e7ea8e 100644 --- a/src/cmd/create/public.rs +++ b/src/cmd/create/public.rs @@ -75,72 +75,72 @@ impl CreatePublic { pub async fn run(self) -> anyhow::Result<()> { log::debug!("creating new client: {}", self.common.name); - let mut config = Config::load(self.config.as_deref())?; - - if !self.common.force && config.clients.contains_key(&self.common.name) { - bail!( - "A client named '{}' already exists. You need to delete it first or use --force", - self.common.name - ); - } - - let http = create_client(&self.http).await?; - - let provider_metadata = - CoreProviderMetadata::discover_async(self.common.issuer.clone(), &http).await?; - - let client = CoreClient::from_provider_metadata( - provider_metadata, - ClientId::new(self.client_id.clone()), - self.client_secret.clone().map(ClientSecret::new), - ); - - let token = match self.refresh_token { - None => self.code_flow(&http, &client).await?, - Some(refresh_token) => { - refresh_token_request(&http, &client, self.common.scope.as_deref(), refresh_token) - .await? + Config::locked(self.config.as_deref(), async |config| { + if !self.common.force && config.clients.contains_key(&self.common.name) { + bail!( + "A client named '{}' already exists. You need to delete it first or use --force", + self.common.name + ); } - }; - - // log info - - log::info!("First token:"); - log::info!( - " ID: {}", - OrNone( - &token - .extra_fields() - .id_token() - .cloned() - .map(|t| t.to_string()) - ) - ); - log::info!(" Access: {}", token.access_token().clone().into_secret()); - log::info!( - " Refresh: {}", - OrNone(&token.refresh_token().cloned().map(|t| t.into_secret())) - ); - // create client + let http = create_client(&self.http).await?; - let client = Client { - issuer_url: self.common.issuer, - scope: self.common.scope, - r#type: ClientType::Public { - client_id: self.client_id, - client_secret: self.client_secret, - }, - state: Some(token.into()), - }; + let provider_metadata = + CoreProviderMetadata::discover_async(self.common.issuer.clone(), &http).await?; - config - .clients - .insert(self.common.name.clone(), client.clone()); + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(self.client_id.clone()), + self.client_secret.clone().map(ClientSecret::new), + ); - config.store(self.config.as_deref())?; + let token = match &self.refresh_token { + None => self.code_flow(&http, &client).await?, + Some(refresh_token) => { + refresh_token_request( + &http, + &client, + self.common.scope.as_deref(), + refresh_token.clone(), + ) + .await? + } + }; + + log::info!("First token:"); + log::info!( + " ID: {}", + OrNone( + &token + .extra_fields() + .id_token() + .cloned() + .map(|t| t.to_string()) + ) + ); + log::info!(" Access: {}", token.access_token().clone().into_secret()); + log::info!( + " Refresh: {}", + OrNone(&token.refresh_token().cloned().map(|t| t.into_secret())) + ); - Ok(()) + let client = Client { + issuer_url: self.common.issuer.clone(), + scope: self.common.scope.clone(), + r#type: ClientType::Public { + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + }, + state: Some(token.into()), + }; + + config + .clients + .insert(self.common.name.clone(), client.clone()); + + Ok(()) + }) + .await } fn bind_mode(&self) -> Bind { @@ -191,12 +191,8 @@ Open the following URL in your browser and perform the interactive login process ); } - // receive the result from the local server - let result = server.receive_token().await?; - // validate CSRF token - match result.state { None => { bail!("missing 'state' parameter from server"); @@ -207,16 +203,12 @@ Open the following URL in your browser and perform the interactive login process Some(_) => {} } - // fetch token - let token = client .exchange_code(AuthorizationCode::new(result.code))? .set_pkce_verifier(pkce_verifier) .request_async(http) .await?; - // check ID token - if let Some(id_token) = token.extra_fields().id_token() { let scopes = self.common.scope.as_deref(); let verifier = diff --git a/src/cmd/delete.rs b/src/cmd/delete.rs index a96c6bb..6fe1e7c 100644 --- a/src/cmd/delete.rs +++ b/src/cmd/delete.rs @@ -15,15 +15,14 @@ impl Delete { pub async fn run(self) -> anyhow::Result<()> { log::debug!("deleting client: {}", self.name); - let mut config = Config::load(self.config.as_deref())?; - - if config.clients.remove(&self.name).is_some() { - log::info!("deleted client: {}", self.name); - config.store(self.config.as_deref())?; - } else { - log::info!("client did not exist: {}", self.name); - } - - Ok(()) + Config::locked(self.config.as_deref(), async |config| { + if config.clients.remove(&self.name).is_some() { + log::info!("deleted client: {}", self.name); + } else { + log::info!("client did not exist: {}", self.name); + } + Ok(()) + }) + .await } } diff --git a/src/cmd/mcp.rs b/src/cmd/mcp.rs index e0606b6..98b62e0 100644 --- a/src/cmd/mcp.rs +++ b/src/cmd/mcp.rs @@ -82,38 +82,38 @@ impl OidcMcpServer { &self, Parameters(params): Parameters, ) -> Result { - let mut config = Config::load(self.config_path.as_deref()).map_err(|e| { - rmcp::ErrorData::internal_error(format!("failed to load config: {e}"), None) - })?; - - let client = config.by_name_mut(¶ms.name).ok_or_else(|| { - rmcp::ErrorData::invalid_params(format!("unknown client '{}'", params.name), None) - })?; - - let token = get_token(client, &self.http).await.map_err(|e| { - rmcp::ErrorData::internal_error(format!("failed to get token: {e}"), None) - })?; + let http = self.http.clone(); + let token_type = params.token_type.clone(); + + let token_value = Config::locked(self.config_path.as_deref(), async |config| { + let client = config + .by_name_mut(¶ms.name) + .ok_or_else(|| anyhow::anyhow!("unknown client '{}'", params.name))?; + + let token = get_token(client, &http).await?; + + let state = match token { + TokenResult::Refreshed(state) => { + client.state = Some(state.clone()); + state + } + TokenResult::Existing(state) => state, + }; - let state = match token { - TokenResult::Refreshed(state) => { - client.state = Some(state.clone()); - config.store(self.config_path.as_deref()).map_err(|e| { - rmcp::ErrorData::internal_error(format!("failed to store config: {e}"), None) - })?; - state - } - TokenResult::Existing(state) => state, - }; + let token_value = match token_type.as_str() { + "id" => state + .id_token + .ok_or_else(|| anyhow::anyhow!("ID token not available"))?, + "refresh" => state + .refresh_token + .ok_or_else(|| anyhow::anyhow!("refresh token not available"))?, + _ => state.access_token, + }; - let token_value = match params.token_type.as_str() { - "id" => state - .id_token - .ok_or_else(|| rmcp::ErrorData::invalid_params("ID token not available", None))?, - "refresh" => state.refresh_token.ok_or_else(|| { - rmcp::ErrorData::invalid_params("refresh token not available", None) - })?, - _ => state.access_token, - }; + Ok(token_value) + }) + .await + .map_err(|e| rmcp::ErrorData::internal_error(format!("{e}"), None))?; Ok(CallToolResult::success(vec![ContentBlock::text( token_value, diff --git a/src/cmd/token.rs b/src/cmd/token.rs index b0f56f3..35815b0 100644 --- a/src/cmd/token.rs +++ b/src/cmd/token.rs @@ -51,42 +51,40 @@ pub struct GetToken { impl GetToken { pub async fn run(self) -> anyhow::Result<()> { - let mut config = Config::load(self.config.as_deref())?; - - let client = config - .by_name_mut(&self.name) - .ok_or_else(|| anyhow!("unknown client '{}'", self.name))?; - - let token = match self.force { - true => fetch_token(client, &self.http).await?, - false => get_token(client, &self.http).await?, - }; - - let token = match token { - TokenResult::Refreshed(token) => { - log::info!("Got a refreshed token. Storing new state."); - // update client state - client.state = Some(token.clone()); - - config.store(self.config.as_deref())?; - - token - } - TokenResult::Existing(token) => token, - }; - - let token = if self.id { - token - .id_token - .ok_or_else(|| anyhow!("ID token not available"))? - } else if self.refresh { - token - .refresh_token - .ok_or_else(|| anyhow!("refresh token not available"))? - } else { - // access is the default - token.access_token - }; + let token = Config::locked(self.config.as_deref(), async |config| { + let client = config + .by_name_mut(&self.name) + .ok_or_else(|| anyhow!("unknown client '{}'", self.name))?; + + let token = match self.force { + true => fetch_token(client, &self.http).await?, + false => get_token(client, &self.http).await?, + }; + + let state = match token { + TokenResult::Refreshed(token) => { + log::info!("Got a refreshed token. Storing new state."); + client.state = Some(token.clone()); + token + } + TokenResult::Existing(token) => token, + }; + + let token = if self.id { + state + .id_token + .ok_or_else(|| anyhow!("ID token not available"))? + } else if self.refresh { + state + .refresh_token + .ok_or_else(|| anyhow!("refresh token not available"))? + } else { + state.access_token + }; + + Ok(token) + }) + .await?; match (self.bearer, self.header, self.inspect) { (true, _, _) => { diff --git a/src/config.rs b/src/config.rs index d7c9e6f..33607bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,14 +43,7 @@ impl Config { } } - pub fn store(&self, path: Option>) -> anyhow::Result<()> { - match path { - Some(path) => self.store_to(path), - None => self.store_to(Self::default_file_err()?), - } - } - - pub fn store_to(&self, path: impl AsRef) -> anyhow::Result<()> { + fn store_to(&self, path: impl AsRef) -> anyhow::Result<()> { let path = path.as_ref(); log::debug!("storing configuration to: {}", path.display()); @@ -79,6 +72,53 @@ impl Config { pub fn by_name_mut(&mut self, name: &str) -> Option<&mut Client> { self.clients.get_mut(name) } + + /// Execute an async closure with exclusive file-system lock on the config. + /// + /// Acquires an advisory lock on a sidecar `.lock` file, loads the config, + /// passes it to the closure, and stores the config back if the closure + /// returns `Ok`. The lock is released when the file is dropped. + pub async fn locked(path: Option<&Path>, f: F) -> anyhow::Result + where + F: AsyncFnOnce(&mut Config) -> anyhow::Result, + { + let config_path = match path { + Some(p) => p.to_path_buf(), + None => Self::default_file_err()?, + }; + let lock_path = lock_path_for(&config_path); + + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating lock file directory: {}", parent.display()))?; + } + + let lock_file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("opening lock file: {}", lock_path.display()))?; + + let lock_file = tokio::task::spawn_blocking(move || lock_file.lock().map(|()| lock_file)) + .await? + .with_context(|| format!("acquiring lock: {}", lock_path.display()))?; + + let mut config = Self::load_from(&config_path)?; + let result = f(&mut config).await?; + config.store_to(&config_path)?; + + drop(lock_file); + + Ok(result) + } +} + +/// Derive the lock file path from a config file path. +fn lock_path_for(config_path: &Path) -> PathBuf { + let mut lock_path = config_path.as_os_str().to_owned(); + lock_path.push(".lock"); + PathBuf::from(lock_path) } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]