Skip to content
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
20 changes: 19 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

rust:
- stable
- "1.88" # MSRV
- "1.89" # MSRV

os:
- ubuntu-latest
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
/.idea
/.serena
128 changes: 128 additions & 0 deletions Cargo.lock

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

8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
]
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 36 additions & 37 deletions src/cmd/create/confidential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading
Loading