nix: extraMcpServers http entries can read request headers from one file via headersHelper
This commit is contained in:
parent
62ab45297c
commit
53eed2a757
3 changed files with 79 additions and 1 deletions
|
|
@ -21,6 +21,11 @@ the tools land in claude as
|
||||||
toggle (default `false`) — see
|
toggle (default `false`) — see
|
||||||
[`docs/tools/subagent.md`](../tools/subagent.md#mcp-servers-available-to-a-subagent)
|
[`docs/tools/subagent.md`](../tools/subagent.md#mcp-servers-available-to-a-subagent)
|
||||||
for what reaches a subagent's own `--mcp-config` and what doesn't.
|
for what reaches a subagent's own `--mcp-config` and what doesn't.
|
||||||
|
An `"http"` entry that needs request headers (a bearer-authenticated remote
|
||||||
|
server) sets `headersFile` to an absolute path: a file holding a JSON object
|
||||||
|
of header name to value. It renders as claude's `headersHelper` (`cat` of the
|
||||||
|
file), so the secret never enters the nix store, and claude re-reads a rotated token
|
||||||
|
on its next reconnect. ACP agents don't read it.
|
||||||
|
|
||||||
The agent's permissions gate tool access
|
The agent's permissions gate tool access
|
||||||
([`docs/process/conventions.md#permissions`](../process/conventions.md)).
|
([`docs/process/conventions.md#permissions`](../process/conventions.md)).
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ pub enum ExtraMcpServer {
|
||||||
},
|
},
|
||||||
Http {
|
Http {
|
||||||
url: String,
|
url: String,
|
||||||
|
/// Absolute path to a file holding a JSON object of request headers
|
||||||
|
/// (`{"Authorization": "Bearer ..."}`). Rendered as claude's
|
||||||
|
/// `headersHelper` so the secret stays in the file, never in the nix
|
||||||
|
/// store or this config.
|
||||||
|
#[serde(default, rename = "headersFile")]
|
||||||
|
headers_file: Option<String>,
|
||||||
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
||||||
allowed_tools: Vec<String>,
|
allowed_tools: Vec<String>,
|
||||||
#[serde(default, rename = "availableToSubagents")]
|
#[serde(default, rename = "availableToSubagents")]
|
||||||
|
|
@ -92,10 +98,29 @@ impl ExtraMcpServer {
|
||||||
.or_insert_with(|| state_dir.display().to_string());
|
.or_insert_with(|| state_dir.display().to_string());
|
||||||
serde_json::json!({ "command": command, "args": args, "env": env })
|
serde_json::json!({ "command": command, "args": args, "env": env })
|
||||||
}
|
}
|
||||||
Self::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }),
|
Self::Http {
|
||||||
|
url, headers_file, ..
|
||||||
|
} => {
|
||||||
|
let mut entry = serde_json::json!({ "type": "http", "url": url });
|
||||||
|
if let Some(path) = headers_file {
|
||||||
|
// claude runs this in a shell at session start, on
|
||||||
|
// reconnect and after a 401/403, and reads a JSON object
|
||||||
|
// of headers from its stdout - so `cat` of the file is the
|
||||||
|
// whole helper, and a rotated token is picked up on the
|
||||||
|
// next reconnect.
|
||||||
|
entry["headersHelper"] =
|
||||||
|
serde_json::Value::String(format!("cat {}", shell_single_quote(path)));
|
||||||
|
}
|
||||||
|
entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `s` as one single-quoted shell word.
|
||||||
|
fn shell_single_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
fn default_allowed_tools() -> Vec<String> {
|
fn default_allowed_tools() -> Vec<String> {
|
||||||
vec!["*".to_owned()]
|
vec!["*".to_owned()]
|
||||||
|
|
@ -183,4 +208,31 @@ mod tests {
|
||||||
assert_eq!(entry["url"], "http://127.0.0.1:1/mcp");
|
assert_eq!(entry["url"], "http://127.0.0.1:1/mcp");
|
||||||
assert!(entry.get("env").is_none());
|
assert!(entry.get("env").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_headers_file_renders_a_headers_helper() {
|
||||||
|
let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://x.example/mcp",
|
||||||
|
"headersFile": "/run/secrets/it's a header file",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let entry = http.to_json_entry(Path::new("/agents/x/state"));
|
||||||
|
assert_eq!(entry["url"], "https://x.example/mcp");
|
||||||
|
assert_eq!(
|
||||||
|
entry["headersHelper"],
|
||||||
|
"cat '/run/secrets/it'\\''s a header file'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_without_headers_file_has_no_helper() {
|
||||||
|
let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:1/mcp",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let entry = http.to_json_entry(Path::new("/agents/x/state"));
|
||||||
|
assert!(entry.get("headersHelper").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,23 @@ in
|
||||||
`type = "http"`; ignored (leave `null`) for `"stdio"`.
|
`type = "http"`; ignored (leave `null`) for `"stdio"`.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
headersFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "/agents/janet/state/secrets/truth-headers.json";
|
||||||
|
description = ''
|
||||||
|
Absolute path (a string, never a nix path literal, which would
|
||||||
|
copy the secret into the world-readable store) to a file holding
|
||||||
|
a JSON object of request headers, e.g.
|
||||||
|
`{"Authorization": "Bearer <token>"}`, for an `"http"` server
|
||||||
|
that needs them. Rendered as claude's `headersHelper`
|
||||||
|
(`cat <file>`), so the file is read when claude connects, on
|
||||||
|
reconnect and after a 401/403: a rotated token needs no
|
||||||
|
harness restart. A missing or non-JSON file leaves that server
|
||||||
|
without headers (claude's own behaviour). `"http"` only; not
|
||||||
|
applied to ACP agents.
|
||||||
|
'';
|
||||||
|
};
|
||||||
allowedTools = lib.mkOption {
|
allowedTools = lib.mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = lib.types.listOf lib.types.str;
|
||||||
default = [ "*" ];
|
default = [ "*" ];
|
||||||
|
|
@ -250,6 +267,10 @@ in
|
||||||
assertion = spec.type != "stdio" || spec.command != null;
|
assertion = spec.type != "stdio" || spec.command != null;
|
||||||
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set";
|
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set";
|
||||||
}) config.services.hyperhive.agent.extraMcpServers
|
}) config.services.hyperhive.agent.extraMcpServers
|
||||||
|
++ lib.mapAttrsToList (name: spec: {
|
||||||
|
assertion = spec.headersFile == null || (spec.type == "http" && lib.hasPrefix "/" spec.headersFile);
|
||||||
|
message = "services.hyperhive.agent.extraMcpServers.${name}: headersFile needs type = \"http\" and an absolute path string";
|
||||||
|
}) config.services.hyperhive.agent.extraMcpServers
|
||||||
++ lib.mapAttrsToList (name: spec: {
|
++ lib.mapAttrsToList (name: spec: {
|
||||||
assertion = spec.type != "http" || spec.url != null;
|
assertion = spec.type != "http" || spec.url != null;
|
||||||
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"http\" requires `url` to be set";
|
message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"http\" requires `url` to be set";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue