1use std::path::Path;
2use std::sync::Arc;
3
4use claude_code_sdk_rust::mcp::{MCPContent, SdkMcpTool, SimpleMCPServer};
5use claude_code_sdk_rust::{ClaudeAgentOptions, PermissionMode, PermissionResult, SettingSource};
6use roder_api::inference::{AgentInferenceRequest, ToolCallCompleted, TurnToolExecutor};
7use roder_api::tools::ToolChoice;
8
9use crate::provider::ClaudeCodeConfig;
10
11pub fn build_options(
12 config: &ClaudeCodeConfig,
13 request: &AgentInferenceRequest,
14 tool_executor: Option<Arc<dyn TurnToolExecutor>>,
15 cwd: Option<&Path>,
16 resume_session_id: Option<&str>,
17) -> anyhow::Result<ClaudeAgentOptions> {
18 let mut builder = ClaudeAgentOptions::builder()
19 .model(request.model.model.clone())
20 .include_partial_messages(true);
21 if let Some(session_id) = resume_session_id.filter(|value| !value.trim().is_empty()) {
27 builder = builder.resume(session_id.to_string());
28 }
29 if let Some(cli_path) = config
30 .cli_path
31 .as_deref()
32 .filter(|value| !value.trim().is_empty())
33 {
34 builder = builder.cli_path(cli_path.to_string());
35 }
36 if let Some(cwd) = cwd {
37 builder = builder.cwd(cwd.display().to_string());
38 }
39 if let Some(system) = merged_system_prompt(request) {
40 builder = builder.system_prompt(system);
41 }
42 if let Some(mode) = config.permission_mode.as_deref() {
43 builder = builder.permission_mode(parse_permission_mode(mode)?);
44 }
45 if let Some(setting_sources) = &config.setting_sources {
46 builder = builder.setting_sources(parse_setting_sources(setting_sources)?);
47 }
48 if request.reasoning.enabled
49 && let Some(level) = request.reasoning.level.as_deref()
50 {
51 builder = builder.effort(level.to_string());
52 }
53 if let Some(max_tokens) = request.output.max_tokens {
54 let budget = i32::try_from(max_tokens).unwrap_or(i32::MAX);
55 builder = builder.max_thinking_tokens(budget);
56 }
57 let allowed_tool_names = allowed_claude_tool_names(request);
58 if !request.tools.is_empty() && !matches!(request.tool_choice, ToolChoice::None) {
59 let executor = tool_executor.ok_or_else(|| {
60 anyhow::anyhow!("Claude Code provider requires a Roder tool executor")
61 })?;
62 let server = roder_sdk_mcp_server(request, executor);
63 builder = builder.sdk_mcp_server("roder", server);
64 builder = builder.tools(Vec::new());
71 builder = builder.allowed_tools(
72 allowed_tool_names
73 .iter()
74 .map(|name| format!("mcp__roder__{name}"))
75 .collect(),
76 );
77 }
78 builder = builder.can_use_tool(move |tool_name, _input, _context| {
79 let allowed_tool_names = allowed_tool_names.clone();
80 async move {
81 let Some(claude_tool_name) = tool_name.strip_prefix("mcp__roder__") else {
82 return Ok(PermissionResult::deny(format!(
83 "Claude Code tool {tool_name} is not managed by Roder"
84 )));
85 };
86 if allowed_tool_names
87 .iter()
88 .any(|name| name == claude_tool_name)
89 {
90 Ok(PermissionResult::allow())
91 } else {
92 Ok(PermissionResult::deny(format!(
93 "Claude Code tool {tool_name} was not advertised for this Roder turn"
94 )))
95 }
96 }
97 });
98 Ok(builder.build())
99}
100
101fn allowed_claude_tool_names(request: &AgentInferenceRequest) -> Vec<String> {
102 request
103 .tools
104 .iter()
105 .flat_map(|tool| {
106 let mut names = vec![tool.name.clone()];
107 names.extend(claude_aliases_for_roder_tool(&tool.name).map(str::to_string));
108 names
109 })
110 .collect()
111}
112
113fn roder_sdk_mcp_server(
114 request: &AgentInferenceRequest,
115 executor: Arc<dyn TurnToolExecutor>,
116) -> SimpleMCPServer {
117 let tools = request
118 .tools
119 .iter()
120 .flat_map(|spec| {
121 let mut names = vec![spec.name.clone()];
122 names.extend(claude_aliases_for_roder_tool(&spec.name).map(str::to_string));
123 names.into_iter().map(|claude_name| {
124 sdk_tool_for_spec(
125 claude_name,
126 spec.name.clone(),
127 spec.description.clone(),
128 spec.parameters.clone(),
129 Arc::clone(&executor),
130 )
131 })
132 })
133 .collect();
134 claude_code_sdk_rust::mcp::create_sdk_mcp_server("roder", tools)
135}
136
137fn sdk_tool_for_spec(
138 claude_name: String,
139 roder_name: String,
140 description: String,
141 schema: serde_json::Value,
142 executor: Arc<dyn TurnToolExecutor>,
143) -> SdkMcpTool {
144 let call_schema = schema.clone();
145 SdkMcpTool::new(
146 claude_name.clone(),
147 description,
148 schema,
149 None,
150 move |input| {
151 let input =
152 repair_sdk_mcp_input_for_tool(input, &call_schema, &claude_name, &roder_name);
153 let call = ToolCallCompleted {
154 id: format!("claude-code-{claude_name}"),
155 name: roder_name.clone(),
156 arguments: input.to_string(),
157 };
158 let handle = tokio::runtime::Handle::current();
159 let outcome = tokio::task::block_in_place(|| handle.block_on(executor.execute(call)));
160 match outcome {
161 Ok(outcome) => {
162 let text = if outcome.is_error {
163 format!("Tool returned an error:\n{}", outcome.result)
164 } else {
165 outcome.result
166 };
167 Ok(vec![MCPContent::Text { text }])
168 }
169 Err(err) => Err(err.to_string()),
170 }
171 },
172 )
173}
174
175fn claude_aliases_for_roder_tool(name: &str) -> impl Iterator<Item = &'static str> {
176 match name {
177 "shell" => ["Bash"].as_slice().iter().copied(),
178 "read_file" => ["Read"].as_slice().iter().copied(),
179 "list_files" => ["LS"].as_slice().iter().copied(),
180 "grep" => ["Grep"].as_slice().iter().copied(),
181 "glob" => ["Glob"].as_slice().iter().copied(),
182 "write_file" => ["Write"].as_slice().iter().copied(),
183 "edit" => ["Edit"].as_slice().iter().copied(),
184 "multi_edit" => ["MultiEdit"].as_slice().iter().copied(),
185 _ => [].as_slice().iter().copied(),
186 }
187}
188
189fn retain_schema_properties(
190 object: &mut serde_json::Map<String, serde_json::Value>,
191 schema: &serde_json::Value,
192) {
193 let Some(properties) = schema
194 .get("properties")
195 .and_then(serde_json::Value::as_object)
196 else {
197 return;
198 };
199 object.retain(|key, _| properties.contains_key(key));
200}
201
202fn repair_sdk_mcp_input_for_tool(
203 input: serde_json::Value,
204 schema: &serde_json::Value,
205 claude_name: &str,
206 roder_name: &str,
207) -> serde_json::Value {
208 if let Some(mut object) = input.as_object().cloned() {
209 normalize_claude_tool_input_aliases(&mut object, claude_name, roder_name);
210 normalize_sdk_mcp_aliases(&mut object, schema);
211 retain_schema_properties(&mut object, schema);
212 return serde_json::Value::Object(object);
213 }
214 let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) else {
215 return input;
216 };
217 let [only_required] = required.as_slice() else {
218 return input;
219 };
220 let Some(property) = only_required.as_str() else {
221 return input;
222 };
223 let value = if property == "command" {
224 command_value_from_sdk_mcp_input(input)
225 } else {
226 input
227 };
228 serde_json::json!({ property: value })
229}
230
231fn normalize_claude_tool_input_aliases(
232 object: &mut serde_json::Map<String, serde_json::Value>,
233 claude_name: &str,
234 roder_name: &str,
235) {
236 match (claude_name, roder_name) {
237 ("Bash", "shell") => move_key_if_missing(object, "command", "command"),
238 ("Read", "read_file") => move_key_if_missing(object, "file_path", "path"),
239 ("LS", "list_files") => move_key_if_missing(object, "path", "path"),
240 ("Grep", "grep") => {
241 move_key_if_missing(object, "pattern", "query");
242 move_key_if_missing(object, "path", "path");
243 move_key_if_missing(object, "glob", "glob");
244 }
245 ("Glob", "glob") => move_key_if_missing(object, "pattern", "pattern"),
246 ("Write", "write_file") => move_key_if_missing(object, "file_path", "path"),
247 ("Edit", "edit") | ("MultiEdit", "multi_edit") => {
248 move_key_if_missing(object, "file_path", "path")
249 }
250 _ => {}
251 }
252}
253
254fn move_key_if_missing(
255 object: &mut serde_json::Map<String, serde_json::Value>,
256 from: &str,
257 to: &str,
258) {
259 if !object.contains_key(to)
260 && let Some(value) = object.remove(from)
261 {
262 object.insert(to.to_string(), value);
263 }
264}
265
266fn command_value_from_sdk_mcp_input(input: serde_json::Value) -> serde_json::Value {
267 let Some(items) = input.as_array() else {
268 return input;
269 };
270 let command = items
271 .iter()
272 .map(|item| match item {
273 serde_json::Value::String(text) => text.clone(),
274 other => other.to_string(),
275 })
276 .collect::<Vec<_>>()
277 .join(" ");
278 serde_json::Value::String(command)
279}
280
281fn normalize_sdk_mcp_aliases(
282 object: &mut serde_json::Map<String, serde_json::Value>,
283 schema: &serde_json::Value,
284) {
285 if schema
286 .get("properties")
287 .and_then(serde_json::Value::as_object)
288 .is_some_and(|properties| properties.contains_key("path"))
289 && !object.contains_key("path")
290 && let Some(value) = object.remove("file_path")
291 {
292 object.insert("path".to_string(), value);
293 }
294}
295
296fn merged_system_prompt(request: &AgentInferenceRequest) -> Option<String> {
297 match (
298 request.instructions.system.as_deref(),
299 request.instructions.developer.as_deref(),
300 ) {
301 (Some(system), Some(developer)) => Some(format!("{system}\n\n{developer}")),
302 (Some(system), None) => Some(system.to_string()),
303 (None, Some(developer)) => Some(developer.to_string()),
304 (None, None) => None,
305 }
306}
307
308pub fn parse_permission_mode(value: &str) -> anyhow::Result<PermissionMode> {
309 match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
310 "default" => Ok(PermissionMode::Default),
311 "accept-edits" | "acceptedits" => Ok(PermissionMode::AcceptEdits),
312 "plan" => Ok(PermissionMode::Plan),
313 "bypass-permissions" | "bypasspermissions" => Ok(PermissionMode::BypassPermissions),
314 "dont-ask" | "dontask" => Ok(PermissionMode::DontAsk),
315 "auto" => Ok(PermissionMode::Auto),
316 other => anyhow::bail!(
317 "unsupported Claude Code permission mode {other:?}; expected default, accept-edits, plan, bypass-permissions, dont-ask, or auto"
318 ),
319 }
320}
321
322pub fn parse_setting_sources(values: &[String]) -> anyhow::Result<Vec<SettingSource>> {
323 values
324 .iter()
325 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
326 "user" => Ok(SettingSource::User),
327 "project" => Ok(SettingSource::Project),
328 "local" => Ok(SettingSource::Local),
329 other => anyhow::bail!(
330 "unsupported Claude Code setting source {other:?}; expected user, project, or local"
331 ),
332 })
333 .collect()
334}
335
336#[cfg(test)]
337mod tests {
338 use roder_api::inference::{
339 AgentInferenceRequest, HostedWebSearchConfig, InstructionBundle, ModelSelection,
340 OutputConfig, ReasoningConfig, RuntimeHints, TurnToolOutcome,
341 };
342 use roder_api::tools::{ToolChoice, ToolSpec};
343
344 use super::*;
345
346 #[test]
347 fn parses_permission_modes() {
348 assert!(matches!(
349 parse_permission_mode("accept-edits").unwrap(),
350 PermissionMode::AcceptEdits
351 ));
352 assert!(parse_permission_mode("root").is_err());
353 }
354
355 #[test]
356 fn builds_options_without_running_claude() {
357 let request = AgentInferenceRequest {
358 model: ModelSelection {
359 provider: "claude-code".to_string(),
360 model: "sonnet".to_string(),
361 },
362 instructions: InstructionBundle {
363 system: Some("system".to_string()),
364 developer: Some("developer".to_string()),
365 developer_context: None,
366 },
367 transcript: Vec::new(),
368 tools: Vec::new(),
369 tool_choice: ToolChoice::Auto,
370 reasoning: ReasoningConfig {
371 enabled: true,
372 level: Some("medium".to_string()),
373 },
374 output: OutputConfig {
375 max_tokens: Some(1024),
376 temperature: None,
377 top_p: None,
378 response_format: None,
379 },
380 runtime: RuntimeHints {
381 hosted_web_search: HostedWebSearchConfig::disabled(),
382 ..RuntimeHints::default()
383 },
384 metadata: serde_json::json!({}),
385 };
386 let options = build_options(
387 &ClaudeCodeConfig {
388 cli_path: Some("/bin/claude".to_string()),
389 permission_mode: Some("default".to_string()),
390 setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
391 workspace: None,
392 reuse_cli_session: None,
393 },
394 &request,
395 None,
396 None,
397 None,
398 )
399 .unwrap();
400 assert_eq!(options.model.as_deref(), Some("sonnet"));
401 assert_eq!(options.cli_path.as_deref(), Some("/bin/claude"));
402 assert_eq!(
403 options.system_prompt.as_deref(),
404 Some("system\n\ndeveloper")
405 );
406 assert_eq!(options.effort.as_deref(), Some("medium"));
407 assert!(options.can_use_tool.is_some());
408 }
409
410 struct FakeToolExecutor;
411
412 #[async_trait::async_trait]
413 impl TurnToolExecutor for FakeToolExecutor {
414 async fn execute(&self, _call: ToolCallCompleted) -> anyhow::Result<TurnToolOutcome> {
415 Ok(TurnToolOutcome {
416 result: "ok".to_string(),
417 is_error: false,
418 })
419 }
420 }
421
422 #[test]
423 fn registers_roder_tools_as_sdk_mcp_server() {
424 let mut request = AgentInferenceRequest {
425 model: ModelSelection {
426 provider: "claude-code".to_string(),
427 model: "sonnet".to_string(),
428 },
429 instructions: InstructionBundle::default(),
430 transcript: Vec::new(),
431 tools: vec![ToolSpec {
432 name: "grep".to_string(),
433 description: "Search text".to_string(),
434 parameters: serde_json::json!({
435 "type": "object",
436 "properties": {"query": {"type": "string"}},
437 "required": ["query"],
438 "additionalProperties": false
439 }),
440 }],
441 tool_choice: ToolChoice::Auto,
442 reasoning: ReasoningConfig::default(),
443 output: OutputConfig::default(),
444 runtime: RuntimeHints {
445 hosted_web_search: HostedWebSearchConfig::disabled(),
446 ..RuntimeHints::default()
447 },
448 metadata: serde_json::json!({}),
449 };
450
451 let options = build_options(
452 &ClaudeCodeConfig::default(),
453 &request,
454 Some(Arc::new(FakeToolExecutor)),
455 None,
456 None,
457 )
458 .unwrap();
459
460 assert!(options.mcp_servers.contains_key("roder"));
461 assert_eq!(
462 options.allowed_tools,
463 vec!["mcp__roder__grep", "mcp__roder__Grep"]
464 );
465 let server = options.sdk_mcp_servers.get("roder").unwrap();
466 let mut names = server
467 .list_tools()
468 .into_iter()
469 .map(|tool| tool.name.clone())
470 .collect::<Vec<_>>();
471 names.sort();
472 assert_eq!(names, vec!["Grep", "grep"]);
473
474 assert!(options.tools_set);
477 assert!(options.tools.is_empty());
478
479 request.tool_choice = ToolChoice::None;
480 let options =
481 build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
482 assert!(options.sdk_mcp_servers.is_empty());
483 assert!(options.allowed_tools.is_empty());
484 assert!(!options.tools_set);
486 }
487
488 #[test]
489 fn resume_session_id_is_passed_through_to_options() {
490 let request = AgentInferenceRequest {
491 model: ModelSelection {
492 provider: "claude-code".to_string(),
493 model: "sonnet".to_string(),
494 },
495 instructions: InstructionBundle::default(),
496 transcript: Vec::new(),
497 tools: Vec::new(),
498 tool_choice: ToolChoice::None,
499 reasoning: ReasoningConfig::default(),
500 output: OutputConfig::default(),
501 runtime: RuntimeHints {
502 hosted_web_search: HostedWebSearchConfig::disabled(),
503 ..RuntimeHints::default()
504 },
505 metadata: serde_json::json!({}),
506 };
507
508 let options = build_options(
509 &ClaudeCodeConfig::default(),
510 &request,
511 None,
512 None,
513 Some("session-123"),
514 )
515 .unwrap();
516 assert_eq!(options.resume.as_deref(), Some("session-123"));
517
518 let options =
520 build_options(&ClaudeCodeConfig::default(), &request, None, None, Some(" ")).unwrap();
521 assert!(options.resume.is_none());
522 }
523
524 #[test]
525 fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
526 let schema = serde_json::json!({
527 "type": "object",
528 "properties": {
529 "path": { "type": "string" }
530 },
531 "required": ["path"],
532 "additionalProperties": false
533 });
534
535 assert_eq!(
536 repair_sdk_mcp_input_for_tool(
537 serde_json::json!("crates/roder-ext-claude-code"),
538 &schema,
539 "",
540 "",
541 ),
542 serde_json::json!({"path": "crates/roder-ext-claude-code"})
543 );
544 assert_eq!(
545 repair_sdk_mcp_input_for_tool(
546 serde_json::json!({"path": "Cargo.toml"}),
547 &schema,
548 "",
549 "",
550 ),
551 serde_json::json!({"path": "Cargo.toml"})
552 );
553 assert_eq!(
554 repair_sdk_mcp_input_for_tool(
555 serde_json::json!({"file_path": "README.md"}),
556 &schema,
557 "",
558 "",
559 ),
560 serde_json::json!({"path": "README.md"})
561 );
562 }
563
564 #[test]
565 fn repairs_array_sdk_mcp_input_for_command_property() {
566 let schema = serde_json::json!({
567 "type": "object",
568 "properties": {
569 "command": { "type": "string" }
570 },
571 "required": ["command"],
572 "additionalProperties": false
573 });
574
575 assert_eq!(
576 repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
577 serde_json::json!({"command": "ls -la"})
578 );
579 }
580
581 #[test]
582 fn maps_claude_native_alias_inputs_to_roder_arguments() {
583 let grep_schema = serde_json::json!({
584 "type": "object",
585 "properties": {
586 "query": { "type": "string" },
587 "path": { "type": "string" }
588 },
589 "required": ["query"],
590 "additionalProperties": false
591 });
592 assert_eq!(
593 repair_sdk_mcp_input_for_tool(
594 serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
595 &grep_schema,
596 "Grep",
597 "grep",
598 ),
599 serde_json::json!({"query": "TokenUsage", "path": "crates"})
600 );
601
602 let read_schema = serde_json::json!({
603 "type": "object",
604 "properties": {"path": {"type": "string"}},
605 "required": ["path"],
606 "additionalProperties": false
607 });
608 assert_eq!(
609 repair_sdk_mcp_input_for_tool(
610 serde_json::json!({"file_path": "README.md"}),
611 &read_schema,
612 "Read",
613 "read_file",
614 ),
615 serde_json::json!({"path": "README.md"})
616 );
617 }
618}