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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ windows-sys = { version = "=0.61.2", features = [
] }

[dev-dependencies]
tokio-rustls = { version = "=0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] }
jsonwebtoken = { version = "=11.1.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
tokio = { version = "=1.53.3", features = ["test-util"] }

Expand Down
33 changes: 33 additions & 0 deletions docs/user/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ Kit merges servers from Agent Plugins, `mcp_config` in `~/.kit/config.toml`, `.m

Use `kit config set mcp_config ~/.kit/mcp.json` to select the global MCP file, `kit config get mcp_config` to inspect the saved path, or `kit config unset mcp_config` to remove the override. These commands edit `~/.kit/config.toml`, not the MCP JSON file or its server entries. See [configuration editing](getting-started-and-configuration.md#edit-configuration-from-the-command-line).

## Use a deployment-provided workload assertion

Configure a trusted server with `auth.type: "workload_identity"` to exchange a
mounted JWT without browser login, client registration, or a client secret:

```json
{
"mcpServers": {
"workload-service": {
"type": "streamable-http",
"url": "https://service.example.com/mcp",
"auth": {
"type": "workload_identity",
"tokenFile": "/run/identity/token",
"expectedIssuer": "https://auth.example.com/tenant",
"tokenEndpoint": "https://auth.example.com/tenant/token",
"resource": "https://service.example.com/mcp"
}
}
}
}
```

Keep this configuration and token mount under deployment control. `tokenFile`
must be absolute; symlink rotation is supported. The resource must match the HTTPS
MCP URL, and the token endpoint must share the expected issuer's origin. Kit checks
issuer metadata before sending the assertion, disables redirects, and refreshes
before expiry. No browser or saved-credential fallback occurs.

Do not add static authorization or Host headers. Reusing an unchanged assertion
requires the issuer's explicit replay-policy support. After a process-memory
restore, restart Kit and resume the transcript rather than reuse resident tokens.

## Ask the agent to add a server

Kit does not provide an MCP-specific slash command. In a `kit tui` session, describe the server and the scope that you want:
Expand Down
17 changes: 17 additions & 0 deletions fixtures/workload-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Local HTTPS test identity

These fixtures are exclusively for `src/workload_auth/https_tests.rs`. The
private key is a public, disposable test key, not a deployment credential.
Never trust this CA or use this identity outside tests. The issuing CA's
private key was discarded after signing.

- `ca.pem`: self-signed test CA, trusted only by the test HTTP client.
- `server.der`: leaf certificate for `localhost` and `127.0.0.1`.
- `server-key.der`: unencrypted PKCS#8 RSA key for the leaf certificate.

Generated with OpenSSL using a 2048-bit RSA CA and leaf key, a 100-year
validity period to avoid routine expiry, and leaf extensions
`subjectAltName=DNS:localhost,IP:127.0.0.1`, `basicConstraints=CA:FALSE`,
`keyUsage=digitalSignature,keyEncipherment`, and `extendedKeyUsage=serverAuth`.
The local server uses an ephemeral port and the test client still validates
the chain and hostname; it does not disable TLS verification.
17 changes: 17 additions & 0 deletions fixtures/workload-auth/ca.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICvDCCAaQCCQD9JAqIqRQZ5DANBgkqhkiG9w0BAQsFADAfMR0wGwYDVQQDDBRL
aXQgd29ya2xvYWQgdGVzdCBDQTAgFw0yNjEwMjMxOTQ1MjlaGA8yMTI2MDkyOTE5
NDUyOVowHzEdMBsGA1UEAwwUS2l0IHdvcmtsb2FkIHRlc3QgQ0EwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDF/ak/Vx1B5nzTI2neCGzt1J1n1lXGJ2aY
dBsruuRiuFxNvkd8rxEVH4tqSRJo3JBC/c1guMR0yaH55NJYmO0heNCqiTUTsgZi
yqiWS90AS+Hk/WWA8E9+ninMwlasEnbkarsK5m2MQilDZjSxzlLBJXmPYBqjeLVB
ucr2lUrFk8X8mkKNPIh40Z2AgfeYbuh6RYLn+/weRxavOFesFMt1GPE9vH62rkxU
HGwf12p+wo18Q8vLFOvcEgZKvAifMxjt2y3TWzJ5AEUFYjj48TpNOol9iXRMkCVC
vgIATVm/hP5S9PJdHRiJv7Ui4OUqimmPk62xKP5p8Gr86Flux6shAgMBAAEwDQYJ
KoZIhvcNAQELBQADggEBAEqpDya+Y7CRaSBzKh1xJeEVlphm7b3FLCPtEX7WY6fP
9IPtKM6UO6NzATfC/fYLKgaBv2jgyXZoR9Sl51la1LmQEbI4VepjnuJU9kbPXUZW
S7G6OSKlNJVCivOFqd7rRj/6THt9SaHIk47S2kRETGbCXhPquWwLt5UWtvcnQkz3
MWOsvCsP5Y/7IZLkMH7iOXnKqYjlj+gIYDfwsPi+uxXTY7C559aja+AOZpDpYkKs
q1QX+KiZOVabDUtbHOIrTgFoTniEu/eqQu7uIs+yOq/GPbervq+707eRFnBslnty
TNGe+kZUGzvZQnyV+Nrp0ycIIdj+kSWOQMHyHXGYvzw=
-----END CERTIFICATE-----
Binary file added fixtures/workload-auth/server-key.der
Binary file not shown.
Binary file added fixtures/workload-auth/server.der
Binary file not shown.
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod protocols;
pub mod provider;
pub mod request_budget;
pub mod runtime;
mod workload_auth;
/// Shared internal filesystem and process-lifetime recovery controls.
pub mod resilient_fs {
pub use crate::filesystem::*;
Expand Down
20 changes: 19 additions & 1 deletion src/tools/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod refresh;
)]
pub(crate) mod refresh_tests;
mod subscriptions;
mod workload;
pub use subscriptions::McpSubscriptionTool;
mod schema;
pub use schema::ToolSchema;
Expand Down Expand Up @@ -922,6 +923,17 @@ impl McpAuthResponder for AuthRecorder {
.check_available()
.map_err(McpError::AuthResolution)?;
}
if record
.as_ref()
.and_then(|record| record.oauth.as_ref())
.and_then(auth::Config::workload)
.is_some()
{
return Err(McpError::AuthResolution(
"workload MCP authentication failed; check the provisioned workload identity"
.into(),
));
}
let session = self.oauth_sessions.lock().await.get(&server).cloned();
if let Some(session) = &session {
session
Expand Down Expand Up @@ -1239,6 +1251,12 @@ fn prepare_server(
.with_header(name.as_str(), value.as_str())
.map_err(|error| format!("invalid MCP config for {id}: {error}"))?;
}
if let Some(config) = server.auth.as_ref().and_then(auth::Config::workload) {
transport = transport.with_http_client(Arc::new(workload::Client::new(
&server.url,
config.clone(),
)?));
}
Ok((
McpTransportBinding::StreamableHttp(transport),
ServerRecord {
Expand Down Expand Up @@ -2752,7 +2770,7 @@ impl McpRuntime {
)));
}
let oauth = record.oauth.clone().unwrap_or_default();
if oauth.machine().is_some() {
if oauth.machine().is_some() || oauth.workload().is_some() {
return Err(ToolError::InvalidInput("machine MCP authentication is managed by provisioned credential files, not interactive auth".into()));
}

Expand Down
17 changes: 15 additions & 2 deletions src/tools/mcp/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub enum Config {
Oauth(InteractiveConfig),
#[serde(rename = "client_credentials")]
Machine(super::machine::Config),
#[serde(rename = "workload_identity")]
Workload(crate::workload_auth::Config),
}

impl Default for Config {
Expand All @@ -49,13 +51,20 @@ impl Config {
pub fn machine(&self) -> Option<&super::machine::Config> {
match self {
Self::Machine(config) => Some(config),
Self::Oauth(_) => None,
Self::Oauth(_) | Self::Workload(_) => None,
}
}
pub fn workload(&self) -> Option<&crate::workload_auth::Config> {
match self {
Self::Workload(config) => Some(config),
_ => None,
}
}

fn interactive(&self) -> Result<&InteractiveConfig, String> {
match self {
Self::Oauth(config) => Ok(config),
Self::Machine(_) => {
Self::Machine(_) | Self::Workload(_) => {
Err("machine MCP authentication does not support interactive authorization".into())
}
}
Expand Down Expand Up @@ -143,6 +152,10 @@ pub async fn restore(
config: &Config,
credential_storage: &CredentialStorage,
) -> Result<Option<(String, AuthorizationManager)>, String> {
if config.workload().is_some() {
// Workload acquisition belongs to the HTTP client, never the credential store.
return Ok(None);
}
if let Some(machine) = config.machine() {
return super::machine::authenticate(resource_url, machine)
.await
Expand Down
Loading
Loading