Skip to content

Commit 92cbfd7

Browse files
bb-connorclaude
andauthored
refactor(agent): split mcp_server.rs into integrations/mcp submodule (#342)
Converts apps/agent/src-tauri/src/integrations/mcp_server.rs (2,022 LOC) into integrations/mcp/ with separate files for server lifecycle, JSON-RPC handling, auth, the developer-activity classifier, and tests. No public API change: McpServer is re-exported from integrations::mcp so main.rs is unchanged. Layout: - mcp/mod.rs (13 LOC) — module declarations + McpServer re-export - mcp/server.rs (92 LOC) — bind/serve/shutdown - mcp/rpc.rs (330 LOC) — JSON-RPC dispatch, tool listing/calls, telemetry push - mcp/auth.rs (32 LOC) — bearer-token check - mcp/developer_activity/mod.rs (294 LOC) — policy-check -> activity projection - mcp/developer_activity/shell_tokens.rs (305 LOC) — tokenizer + helpers - mcp/developer_activity/package_command.rs (176 LOC) — npm/pnpm/pip/... - mcp/developer_activity/cloud_command.rs (130 LOC) — aws/gcloud/gh/vercel/... - mcp/developer_activity/secret_targets.rs (62 LOC) — secret-path detection - mcp/developer_activity/metadata.rs (51 LOC) — activity IDs + metadata - mcp/tests.rs (616 LOC) — all 18 tests moved unchanged All production files are below the 600-line cap. The developer-activity classifier stays in-tree per the swarm synthesis (deferred extraction). Co-authored-by: bb-connor <bb-connor@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 478d70b commit 92cbfd7

13 files changed

Lines changed: 2103 additions & 2024 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//! Authentication for incoming MCP JSON-RPC requests.
2+
3+
use crate::agent_auth::read_local_api_token;
4+
use crate::security::auth::constant_time_eq_token;
5+
use axum::http::header::AUTHORIZATION;
6+
use axum::http::HeaderMap;
7+
8+
/// Verify the bearer token on an incoming MCP JSON-RPC request.
9+
pub(super) fn mcp_authorized(headers: &HeaderMap, auth_token: &str) -> bool {
10+
let token = headers
11+
.get(AUTHORIZATION)
12+
.and_then(|value| value.to_str().ok())
13+
.map(str::trim)
14+
.and_then(|value| value.strip_prefix("Bearer "))
15+
.map(str::trim);
16+
17+
let expected_token = match read_local_api_token() {
18+
Ok(token) => token,
19+
Err(err) => {
20+
tracing::warn!(
21+
error = %err,
22+
"Falling back to startup MCP auth token because current token could not be read"
23+
);
24+
auth_token.to_string()
25+
}
26+
};
27+
28+
match token {
29+
Some(candidate) => constant_time_eq_token(candidate, &expected_token),
30+
None => false,
31+
}
32+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! Cloud-CLI command classification (aws/gcloud/az/gh/vercel/etc).
2+
3+
use super::shell_tokens::executable_name;
4+
5+
pub(super) struct CloudCommand {
6+
pub(super) provider: &'static str,
7+
pub(super) image: String,
8+
pub(super) args: Vec<String>,
9+
}
10+
11+
pub(super) fn cloud_command(command: &[String]) -> Option<CloudCommand> {
12+
let image = command.first()?.clone();
13+
let executable = executable_name(&image);
14+
let provider = match executable.as_str() {
15+
"aws" => "aws",
16+
"gcloud" => "gcloud",
17+
"az" => "az",
18+
"gh" => "gh",
19+
"vercel" => "vercel",
20+
"netlify" => "netlify",
21+
"wrangler" => "wrangler",
22+
"doctl" => "doctl",
23+
"fly" | "flyctl" => "fly",
24+
"op" => "op",
25+
"vault" => "vault",
26+
"doppler" => "doppler",
27+
"heroku" => "heroku",
28+
"supabase" => "supabase",
29+
"kubectl" => "kubectl",
30+
"pulumi" => "pulumi",
31+
"circleci" => "circleci",
32+
"glab" => "glab",
33+
"buildkite-agent" | "bk" => "buildkite",
34+
_ => return None,
35+
};
36+
Some(CloudCommand {
37+
provider,
38+
image,
39+
args: command.iter().skip(1).cloned().collect(),
40+
})
41+
}
42+
43+
pub(super) fn cloud_cli_args_are_sensitive(args: &[String]) -> bool {
44+
let joined = args.join(" ").to_ascii_lowercase();
45+
[
46+
"secretsmanager get-secret-value",
47+
"ssm get-parameter",
48+
"ssm get-parameters",
49+
"iam create-access-key",
50+
"iam put-user-policy",
51+
"iam attach-user-policy",
52+
"ecr get-login-password",
53+
"sts get-session-token",
54+
"sts assume-role",
55+
"auth print-access-token",
56+
"secrets versions access",
57+
"iam service-accounts keys create",
58+
"keyvault secret show",
59+
"keyvault secret download",
60+
"account get-access-token",
61+
"ad app credential reset",
62+
"secret set",
63+
"secret put",
64+
"secret bulk",
65+
"secret list",
66+
"secret delete",
67+
"versions secret put",
68+
"versions secret bulk",
69+
"registry docker-config",
70+
"registry login",
71+
"kubernetes cluster kubeconfig save",
72+
"secrets set",
73+
"secrets import",
74+
"secrets unset",
75+
"secrets list",
76+
"tokens create",
77+
"tokens revoke",
78+
"auth token",
79+
"variable set",
80+
"variable update",
81+
"variable delete",
82+
"variable get",
83+
"variable list",
84+
"variable export",
85+
"secret get",
86+
"secret create",
87+
"secret update",
88+
"env pull",
89+
"env add",
90+
"env rm",
91+
"env remove",
92+
"env ls",
93+
"env:get",
94+
"env:list",
95+
"env:set",
96+
"env:import",
97+
"env:unset",
98+
"item get",
99+
"document get",
100+
"op://",
101+
"kv get",
102+
"read secret/",
103+
"token create",
104+
"secrets download",
105+
"configs tokens create",
106+
"config:get",
107+
"config:set",
108+
"secrets pull",
109+
"get secret",
110+
"describe secret",
111+
"config view --raw",
112+
"--show-secrets",
113+
"context store-secret",
114+
"context remove-secret",
115+
"runner token create",
116+
"runner token list",
117+
]
118+
.iter()
119+
.any(|needle| joined.contains(needle))
120+
|| args.iter().any(|arg| {
121+
let arg = arg.to_ascii_lowercase();
122+
arg.contains("secret")
123+
|| arg.contains("token")
124+
|| arg.contains("credential")
125+
|| arg.contains("access-key")
126+
|| arg == "iam"
127+
|| arg == "sts"
128+
|| arg == "keyvault"
129+
})
130+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! Metadata helpers and activity identifiers for developer-activity records.
2+
3+
use crate::policy::PolicyCheckInput;
4+
use hush_core::sha256;
5+
6+
pub(super) fn mcp_shell_activity_metadata(
7+
base_metadata: &serde_json::Value,
8+
input: &PolicyCheckInput,
9+
classifier: &str,
10+
) -> serde_json::Value {
11+
let mut metadata = base_metadata.as_object().cloned().unwrap_or_default();
12+
metadata.insert(
13+
"collectorKind".to_string(),
14+
serde_json::Value::String("mcp_policy_check".to_string()),
15+
);
16+
metadata.insert(
17+
"shellClassifier".to_string(),
18+
serde_json::Value::String(classifier.to_string()),
19+
);
20+
if let Some(content) = input
21+
.content
22+
.as_deref()
23+
.map(str::trim)
24+
.filter(|value| !value.is_empty())
25+
{
26+
metadata.insert(
27+
"policyContent".to_string(),
28+
serde_json::Value::String(content.to_string()),
29+
);
30+
}
31+
serde_json::Value::Object(metadata)
32+
}
33+
34+
pub(super) fn local_mcp_activity_id(
35+
prefix: &str,
36+
session_id: Option<&str>,
37+
agent_id: &str,
38+
target: &str,
39+
) -> String {
40+
let material = format!(
41+
"{prefix}\0{}\0{agent_id}\0{target}",
42+
session_id.unwrap_or_default()
43+
);
44+
let digest = sha256(material.as_bytes()).to_hex_prefixed();
45+
let fragment = digest
46+
.trim_start_matches("0x")
47+
.chars()
48+
.take(32)
49+
.collect::<String>();
50+
format!("{prefix}-{fragment}")
51+
}

0 commit comments

Comments
 (0)