Skip to content

Commit c5b8d7d

Browse files
authored
fix(mcp): repo-less agents keep the PAT read path in multi-installation mode (#40)
Multi-installation mode previously required every [[mcp.agents]] entry to have a non-empty repos allowlist, which broke legacy unrestricted read agents (e.g. a search-capable read-only agent) the moment [[mcp.github_apps]] was configured. - Repo-less agents now skip fan-out and routing entirely: they keep the legacy pooled-PAT read path (initialize pins a PAT session as before) - Writes remain impossible for them: startup validation rejects a repo-less agent allowlisting a write-classified tool when writes are enabled, and the proxy denies write calls from repo-less agents in multi mode as defense-in-depth (writes never run on pooled PATs) Verified: clippy -D warnings clean, 99 passed / 0 failed (2 new tests: PAT read path preserved without fan-out; write denied with 403 even when enable_writes is on) Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 1cfe9ea commit c5b8d7d

2 files changed

Lines changed: 155 additions & 40 deletions

File tree

src/config.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,24 @@ impl McpConfig {
223223
if self.audit.is_none() {
224224
return Err("enable_writes requires [mcp.audit] — writes are fail-closed audited".into());
225225
}
226+
// Multi-installation mode: repo-less agents ride pooled PATs, and
227+
// writes never run on pooled PATs — an agent allowlisting a
228+
// write-classified tool must be repository-scoped.
229+
if !self.github_apps.is_empty() {
230+
for agent in &self.agents {
231+
if agent.repos.is_empty()
232+
&& agent
233+
.tools
234+
.iter()
235+
.any(|t| crate::policy::classify_tool(t) == crate::policy::ToolKind::Write)
236+
{
237+
return Err(format!(
238+
"mcp agent '{}' allowlists write tools but has no `repos` — repo-less agents use pooled PATs and writes never run on pooled PATs",
239+
agent.id
240+
));
241+
}
242+
}
243+
}
226244
}
227245
// Mutual exclusion: singular and plural forms cannot coexist
228246
if self.github_app.is_some() && !self.github_apps.is_empty() {
@@ -244,19 +262,14 @@ impl McpConfig {
244262
}
245263
}
246264
// Multi-installation routing derives each session's credential
247-
// envelope from the agent's repo allowlist, so it needs
248-
// authenticated agents with fully covered, well-formed repos —
249-
// for reads and writes alike.
265+
// envelope from the agent's repo allowlist. Agents WITH repos
266+
// must be fully covered by installations; agents WITHOUT repos
267+
// keep the legacy PAT-backed read path (writes are denied for
268+
// them at the proxy — writes never run on pooled PATs).
250269
if self.agents.is_empty() {
251270
return Err("[[mcp.github_apps]] requires [[mcp.agents]] — multi-installation routing is not available in network-trust mode".into());
252271
}
253272
for agent in &self.agents {
254-
if agent.repos.is_empty() {
255-
return Err(format!(
256-
"mcp agent '{}' requires a non-empty `repos` allowlist in multi-installation mode — the repo owners select the installation",
257-
agent.id
258-
));
259-
}
260273
for repo_entry in &agent.repos {
261274
// Strict form: `owner/name` or `owner/*` — no empty or
262275
// whitespace-padded parts, no extra path segments.
@@ -649,13 +662,26 @@ mod tests {
649662
let m = McpConfig { github_apps: vec![entry("openabdev")], ..Default::default() };
650663
assert!(m.validate().unwrap_err().contains("[[mcp.agents]]"));
651664

652-
// non-empty repos required per agent
665+
// repo-less agents are allowed (legacy PAT read path)…
653666
let m = McpConfig {
654667
github_apps: vec![entry("openabdev")],
655668
agents: vec![multi_agent(&[])],
656669
..Default::default()
657670
};
658-
assert!(m.validate().unwrap_err().contains("non-empty `repos`"));
671+
assert!(m.validate().is_ok());
672+
673+
// …but a repo-less agent allowlisting a WRITE tool with writes
674+
// enabled is a startup error (writes never run on pooled PATs)
675+
let mut wa = multi_agent(&[]);
676+
wa.tools = vec!["issue_read".into(), "create_issue".into()];
677+
let m = McpConfig {
678+
enable_writes: true,
679+
github_apps: vec![entry("openabdev")],
680+
agents: vec![wa],
681+
audit: Some(AuditConfig { path: "/tmp/a.jsonl".into(), max_result_bytes: 1024 }),
682+
..Default::default()
683+
};
684+
assert!(m.validate().unwrap_err().contains("pooled PATs"));
659685

660686
// every repo owner must have a matching installation
661687
let m = McpConfig {

src/mcp.rs

Lines changed: 118 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ pub async fn mcp_proxy(
119119
"write tools are not enabled",
120120
);
121121
}
122+
// 2b. Multi-installation mode: repo-less agents ride pooled
123+
// PATs, and writes never run on pooled PATs — even when
124+
// writes are enabled. (Also rejected at startup
125+
// validation; kept as defense-in-depth.)
126+
if crate::policy::classify_tool(tool_name) == crate::policy::ToolKind::Write
127+
&& state.multi_app_tokens.is_some()
128+
&& agent.repos.is_empty()
129+
{
130+
tracing::warn!(
131+
"MCP tools/call {} DENIED (repo-less agent uses pooled PATs) [agent={}]{}",
132+
tool_name, agent.id, session_suffix(session_id.as_deref())
133+
);
134+
return rpc_error(
135+
StatusCode::FORBIDDEN,
136+
"write tools require a repository-scoped agent",
137+
);
138+
}
122139
// 3. Repository allowlist (deny-if-unresolvable)
123140
if !agent.repos.is_empty() {
124141
match &resolved_repo {
@@ -153,7 +170,8 @@ pub async fn mcp_proxy(
153170

154171
// Multi-installation mode: `initialize` fans out to one upstream session
155172
// per owner in the agent's envelope; DELETE and notifications fan out to
156-
// every pinned route. Everything else is routed per-call below.
173+
// every pinned route. Repo-less agents skip fan-out entirely — they keep
174+
// the legacy PAT-backed path (reads only; writes are denied above).
157175
if state.multi_app_tokens.is_some() {
158176
let frame_method = frame.as_ref().map(|f| f.method.as_str()).unwrap_or("");
159177
if method == Method::POST && frame_method == "initialize" && session_id.is_none() {
@@ -162,7 +180,9 @@ pub async fn mcp_proxy(
162180
// authenticate() already rejected keyless requests.
163181
return rpc_error(StatusCode::UNAUTHORIZED, "agent authentication required");
164182
};
165-
return multi_initialize(&state, &headers, body, agent).await;
183+
if !agent.repos.is_empty() {
184+
return multi_initialize(&state, &headers, body, agent).await;
185+
}
166186
}
167187
if let Some(sid) = session_id.as_deref() {
168188
if method == Method::DELETE
@@ -702,33 +722,32 @@ async fn pick_credential(
702722
// New session, multi-installation mode: stateless call (initialize is
703723
// handled by the fan-out path before credential resolution). Route by
704724
// the resolved owner, or the agent's first owner for repo-less methods.
725+
// Repo-less agents fall through to the PAT pool (legacy read path).
705726
if let Some(multi) = &state.multi_app_tokens {
706-
// Validation guarantees agents exist in multi mode.
707-
let Some(agent) = agent else {
708-
return Err(StatusCode::UNAUTHORIZED);
709-
};
710-
let key = match route_owner {
711-
Some(o) => o.to_lowercase(),
712-
None => match route_owners(agent).into_iter().next() {
713-
Some(o) => o,
714-
None => return Err(StatusCode::BAD_GATEWAY),
715-
},
716-
};
717-
let Some(provider) = multi.get(&key) else {
718-
return Err(StatusCode::FORBIDDEN);
719-
};
720-
let envelope = scope_envelope_for_owner(agent, &key);
721-
return match provider.token_scoped(&envelope).await {
722-
Ok(t) => Ok(McpCredential::Routed {
723-
owner: key,
724-
token: t.token,
725-
upstream_session: None,
726-
}),
727-
Err(e) => {
728-
tracing::error!("App token mint failed for owner {}: {}", key, e);
729-
Err(StatusCode::BAD_GATEWAY)
727+
if let Some(agent) = agent {
728+
let owners = route_owners(agent);
729+
if let Some(first_owner) = owners.first() {
730+
let key = match route_owner {
731+
Some(o) => o.to_lowercase(),
732+
None => first_owner.clone(),
733+
};
734+
let Some(provider) = multi.get(&key) else {
735+
return Err(StatusCode::FORBIDDEN);
736+
};
737+
let envelope = scope_envelope_for_owner(agent, &key);
738+
return match provider.token_scoped(&envelope).await {
739+
Ok(t) => Ok(McpCredential::Routed {
740+
owner: key,
741+
token: t.token,
742+
upstream_session: None,
743+
}),
744+
Err(e) => {
745+
tracing::error!("App token mint failed for owner {}: {}", key, e);
746+
Err(StatusCode::BAD_GATEWAY)
747+
}
748+
};
730749
}
731-
};
750+
}
732751
}
733752
// New session: App backend takes precedence when configured. The token
734753
// is scoped to the agent's repo envelope when possible (exact entries,
@@ -2901,15 +2920,27 @@ mod tests {
29012920
&["issue_read"],
29022921
&["openabdev/openab"],
29032922
),
2923+
// Repo-less agent (like the legacy b2): keeps the PAT-backed
2924+
// read path in multi mode, writes always denied.
2925+
agent_with_repos(
2926+
"b2pat",
2927+
"key-b2pat",
2928+
&["search_code", "issue_read", "create_issue"],
2929+
&[],
2930+
),
29042931
];
29052932
let cache_config = config::CacheConfig::default();
29062933
let has_sink = sink.is_some();
2934+
let identities = vec![config::IdentityConfig {
2935+
id: "alice".into(),
2936+
token: "token-alice".into(),
2937+
}];
29072938
Arc::new(AppState {
2908-
pool: pool::PatPool::new(&[]),
2939+
pool: pool::PatPool::new(&identities),
29092940
cache: cache::Cache::new(&cache_config),
29102941
config: config::Config {
29112942
port: 8080,
2912-
identities: vec![],
2943+
identities: identities.clone(),
29132944
allowed_owners: vec!["openabdev".to_string(), "oablab".to_string()],
29142945
cache: cache_config,
29152946
mcp: config::McpConfig {
@@ -3297,6 +3328,64 @@ mod tests {
32973328
assert!(state.mcp_sessions.get("sess-ghs_oablab").await.is_none());
32983329
}
32993330

3331+
#[tokio::test]
3332+
async fn test_multi_repoless_agent_keeps_pat_read_path() {
3333+
let (url, captured) = spawn_mock_upstream_multi().await;
3334+
let state = test_state_multi(&url, false, None).await;
3335+
3336+
// initialize as the repo-less agent: NO fan-out — single upstream
3337+
// request, served by the pooled PAT, pinned normally
3338+
let resp = mcp_app(state.clone())
3339+
.oneshot(post_frame(MULTI_INIT, &[("x-ghpool-key", "key-b2pat")]))
3340+
.await
3341+
.unwrap();
3342+
assert_eq!(resp.status(), StatusCode::OK);
3343+
assert_eq!(resp.headers().get("mcp-session-id").unwrap(), "sess-token-alice");
3344+
{
3345+
let reqs = captured.lock().unwrap();
3346+
assert_eq!(reqs.len(), 1, "repo-less agent must not fan out");
3347+
assert_eq!(reqs[0].auth.as_deref(), Some("Bearer token-alice"));
3348+
}
3349+
assert_eq!(
3350+
state.mcp_sessions.get("sess-token-alice").await,
3351+
Some(pin("alice", Some("b2pat")))
3352+
);
3353+
3354+
// repo-less read tools (search_code) still work on the session
3355+
let resp = mcp_app(state)
3356+
.oneshot(post_frame(
3357+
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_code","arguments":{"query":"foo"}}}"#,
3358+
&[("x-ghpool-key", "key-b2pat"), ("mcp-session-id", "sess-token-alice")],
3359+
))
3360+
.await
3361+
.unwrap();
3362+
assert_eq!(resp.status(), StatusCode::OK);
3363+
assert_eq!(captured.lock().unwrap().len(), 2);
3364+
}
3365+
3366+
#[tokio::test]
3367+
async fn test_multi_repoless_agent_writes_denied_even_when_enabled() {
3368+
let (url, captured) = spawn_mock_upstream_multi().await;
3369+
let path = audit_tmp("repoless-write");
3370+
let sink = crate::audit::AuditSink::open(&path).unwrap();
3371+
// writes globally enabled — the repo-less agent must still be denied
3372+
let state = test_state_multi(&url, true, Some(sink)).await;
3373+
let resp = mcp_app(state)
3374+
.oneshot(post_frame(
3375+
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_issue","arguments":{"owner":"openabdev","repo":"openab","title":"t"}}}"#,
3376+
&[("x-ghpool-key", "key-b2pat")],
3377+
))
3378+
.await
3379+
.unwrap();
3380+
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3381+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
3382+
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
3383+
assert!(v["error"]["message"].as_str().unwrap().contains("repository-scoped"));
3384+
assert!(captured.lock().unwrap().is_empty());
3385+
assert!(read_audit(&path).is_empty());
3386+
std::fs::remove_file(&path).ok();
3387+
}
3388+
33003389
#[tokio::test]
33013390
async fn test_multi_write_audited_with_installation_label() {
33023391
let (url, captured) = spawn_mock_upstream_multi().await;

0 commit comments

Comments
 (0)