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