embacle_server/uhp/invocation.rs
1// ABOUTME: Applies a harness's skills, MCP servers and disabled tools to the runner a task runs on
2// ABOUTME: A setting the chosen runner has no way to apply refuses the task instead of being dropped
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! What a harness's configuration adds to one task's runner invocation.
8//!
9//! A configured harness carries three settings beyond its model: skill
10//! bundles, MCP servers, and tools withheld from the model. Each reaches the
11//! underlying CLI through whatever that CLI actually takes, and each runner
12//! family is listed in [`mechanism`] with exactly what that is:
13//!
14//! | Base | skills | mcpServers | disabledTools |
15//! |---|---|---|---|
16//! | `claude-code` | `--plugin-dir` | `--mcp-config` | `--disallowed-tools` |
17//! | `copilot` | `--plugin-dir` | `--additional-mcp-config` | `--excluded-tools` |
18//! | `copilot_headless` | — | ACP `session/new` | — |
19//! | every other base | — | — | — |
20//!
21//! A setting a base has no way to apply refuses the task with
22//! `unsupported_harness_setting`, naming the setting and the base. Running the
23//! task without it would hand the client a result produced under a
24//! configuration it did not ask for — a withheld tool offered anyway, a skill
25//! never loaded — and nothing in the response could tell it so.
26//!
27//! Only what is switched on counts: a skill or MCP server with `enabled: false`
28//! is neither applied nor refused.
29
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use embacle::config::CliRunnerType;
34use embacle::types::{McpServerConfig, McpTransport};
35use serde_json::json;
36
37use super::error::{ErrorType, UhpFailure};
38use super::files;
39use super::harnesses::{Harness, McpServer, Skill};
40
41/// The name the staged skill bundles are loaded under.
42///
43/// Both CLIs that load skills namespace a plugin's skills by the plugin's
44/// name, so a bundle named `review` reaches the model as `harness:review`.
45pub const PLUGIN_NAME: &str = "harness";
46
47/// Where a plugin directory keeps its manifest, read by Claude Code and by the
48/// Copilot CLI alike.
49const MANIFEST_PATH: [&str; 2] = [".claude-plugin", "plugin.json"];
50
51/// The folder inside a plugin directory that holds one folder per skill.
52const SKILLS_DIR: &str = "skills";
53
54/// One of the three harness settings a task run applies.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Setting {
57 /// Skill bundles.
58 Skills,
59 /// MCP servers the harness may reach.
60 McpServers,
61 /// Tools withheld from the model.
62 DisabledTools,
63}
64
65impl Setting {
66 /// The setting's name on the wire, which is how a refusal names it.
67 #[must_use]
68 pub const fn wire_name(self) -> &'static str {
69 match self {
70 Self::Skills => "skills",
71 Self::McpServers => "mcpServers",
72 Self::DisabledTools => "disabledTools",
73 }
74 }
75}
76
77/// How one runner family takes each setting, or `None` where it has no way to.
78struct Mechanism {
79 /// The flag that loads a plugin directory holding the skill bundles.
80 skills: Option<&'static str>,
81 /// Whether MCP servers handed over on the request reach the model.
82 mcp_servers: bool,
83 /// The flag that removes a tool from what the model is offered.
84 disabled_tools: Option<&'static str>,
85}
86
87/// What `provider` can apply.
88///
89/// Claude Code and the Copilot CLI both load a plugin directory for one run
90/// (`--plugin-dir`) and read its `skills/<name>/SKILL.md` bundles, and both
91/// take MCP servers on their command line, rendered from the request by their
92/// runners. Claude Code's `--disallowed-tools` and the Copilot CLI's
93/// `--excluded-tools` each remove a tool from the set the model is offered,
94/// rather than only denying its use once called.
95///
96/// Copilot over ACP builds its runner from the environment, not from a
97/// per-task configuration, so no flag reaches it; MCP servers do, through the
98/// `session/new` request its runner sends. The remaining runners take none of
99/// the three: their CLIs read MCP servers, skills and tool policy only from
100/// their own configuration files, which embacle does not write.
101const fn mechanism(provider: CliRunnerType) -> Mechanism {
102 match provider {
103 CliRunnerType::ClaudeCode => Mechanism {
104 skills: Some("--plugin-dir"),
105 mcp_servers: true,
106 disabled_tools: Some("--disallowed-tools"),
107 },
108 CliRunnerType::Copilot => Mechanism {
109 skills: Some("--plugin-dir"),
110 mcp_servers: true,
111 disabled_tools: Some("--excluded-tools"),
112 },
113 CliRunnerType::CopilotHeadless => Mechanism {
114 skills: None,
115 mcp_servers: true,
116 disabled_tools: None,
117 },
118 _ => Mechanism {
119 skills: None,
120 mcp_servers: false,
121 disabled_tools: None,
122 },
123 }
124}
125
126/// Skill bundles written out for one task, removed from disk when dropped.
127///
128/// Held by the task until its runner is done with them: the CLI reads the
129/// bundles while it runs, not when it starts.
130#[derive(Debug)]
131pub struct StagedSkills {
132 dir: PathBuf,
133}
134
135impl StagedSkills {
136 /// The plugin directory the runner is pointed at.
137 #[must_use]
138 pub fn dir(&self) -> &Path {
139 &self.dir
140 }
141
142 /// Write `skills` as one plugin directory at `dir`.
143 ///
144 /// The guard exists before the first write, so a bundle that fails half
145 /// way leaves nothing behind.
146 fn write(dir: PathBuf, skills: &[&Skill]) -> Result<Self, UhpFailure> {
147 let staged = Self { dir };
148 let manifest: PathBuf = MANIFEST_PATH.iter().collect();
149 write_file(
150 &staged.dir.join(manifest),
151 json!({ "name": PLUGIN_NAME }).to_string().as_bytes(),
152 )?;
153
154 for skill in skills {
155 if !skill.has_plain_name() {
156 return Err(invalid_skill(
157 &skill.name,
158 "its name is not one path segment",
159 ));
160 }
161 let root = staged.dir.join(SKILLS_DIR).join(&skill.name);
162 for file in &skill.files {
163 let relative = file
164 .relative_path()
165 .ok_or_else(|| invalid_skill(&skill.name, "a member path leaves the bundle"))?;
166 let bytes = file.bytes().map_err(|_| {
167 invalid_skill(&skill.name, "a member's content_b64 is not base64")
168 })?;
169 write_file(&root.join(relative), &bytes)?;
170 }
171 }
172 Ok(staged)
173 }
174}
175
176impl Drop for StagedSkills {
177 fn drop(&mut self) {
178 // Nothing reads the bundle after its task, and a folder that cannot
179 // be removed has no one left to report to.
180 fs::remove_dir_all(&self.dir).ok();
181 }
182}
183
184/// Write one staged file, creating the folders above it.
185fn write_file(path: &Path, bytes: &[u8]) -> Result<(), UhpFailure> {
186 path.parent()
187 .map_or(Ok(()), fs::create_dir_all)
188 .and_then(|()| fs::write(path, bytes))
189 .map_err(|_| staging_failed())
190}
191
192/// The bundles could not be put where the runner reads them.
193fn staging_failed() -> UhpFailure {
194 UhpFailure::new(
195 ErrorType::ServerError,
196 "skill_staging_failed",
197 "the harness's skills could not be prepared for this task",
198 )
199}
200
201/// A skill bundle that cannot be written as it stands.
202fn invalid_skill(name: &str, why: &str) -> UhpFailure {
203 UhpFailure::new(
204 ErrorType::InvalidRequestError,
205 "invalid_input",
206 format!("skill '{name}' cannot be staged for this task: {why}"),
207 )
208 .with_detail(json!({ "setting": Setting::Skills.wire_name(), "skill": name }))
209}
210
211/// Everything a harness's configuration adds to one task's run.
212#[derive(Debug, Default)]
213pub struct Applied {
214 /// Arguments appended to the runner's command line.
215 pub args: Vec<String>,
216 /// MCP servers handed to the runner on the request.
217 pub mcp_servers: Vec<McpServerConfig>,
218 /// The staged skill bundles, kept on disk while the task holds this.
219 pub skills: Option<StagedSkills>,
220}
221
222/// Render one enabled MCP server the way the runners take it.
223///
224/// The harness object names a server by URL alone, so only the transports
225/// that reach a URL apply: streamable HTTP, which is also what an absent
226/// transport means, and SSE.
227fn mcp_config(server: &McpServer) -> Result<McpServerConfig, UhpFailure> {
228 let refuse = |why: String| {
229 UhpFailure::new(ErrorType::InvalidRequestError, "invalid_input", why).with_detail(
230 json!({ "setting": Setting::McpServers.wire_name(), "server": server.name }),
231 )
232 };
233 let url = server.url.clone().ok_or_else(|| {
234 refuse(format!(
235 "MCP server '{}' is enabled but names no url",
236 server.name
237 ))
238 })?;
239 let headers = Vec::new();
240 let transport = match server.transport.as_deref() {
241 None | Some("http" | "streamable-http" | "streamable_http") => {
242 McpTransport::Http { url, headers }
243 }
244 Some("sse") => McpTransport::Sse { url, headers },
245 Some(other) => {
246 return Err(refuse(format!(
247 "MCP server '{}' uses transport '{other}'; a harness reaches MCP servers over \
248 http or sse",
249 server.name
250 )))
251 }
252 };
253 Ok(McpServerConfig {
254 name: server.name.clone(),
255 transport,
256 })
257}
258
259/// Apply `harness`'s settings to a run of `provider`, the task `task_id`.
260///
261/// # Errors
262///
263/// Answers `400 unsupported_harness_setting` naming every switched-on
264/// setting `provider` has no way to apply, `400 invalid_input` for an MCP
265/// server or skill bundle that cannot be handed over as it stands, and
266/// `500 skill_staging_failed` when the bundles cannot be written.
267pub fn apply(
268 provider: CliRunnerType,
269 harness: &Harness,
270 task_id: &str,
271) -> Result<Applied, UhpFailure> {
272 let mechanism = mechanism(provider);
273 let skills: Vec<&Skill> = harness.skills.iter().filter(|s| s.enabled).collect();
274 let servers: Vec<&McpServer> = harness.mcp_servers.iter().filter(|s| s.enabled).collect();
275
276 let unsupported: Vec<&str> = [
277 (
278 Setting::Skills,
279 !skills.is_empty() && mechanism.skills.is_none(),
280 ),
281 (
282 Setting::McpServers,
283 !servers.is_empty() && !mechanism.mcp_servers,
284 ),
285 (
286 Setting::DisabledTools,
287 !harness.disabled_tools.is_empty() && mechanism.disabled_tools.is_none(),
288 ),
289 ]
290 .into_iter()
291 .filter_map(|(setting, refused)| refused.then_some(setting.wire_name()))
292 .collect();
293 if !unsupported.is_empty() {
294 return Err(UhpFailure::new(
295 ErrorType::InvalidRequestError,
296 "unsupported_harness_setting",
297 format!(
298 "harness '{}' sets {}, which its base '{}' has no way to apply",
299 harness.name,
300 unsupported.join(", "),
301 harness.base
302 ),
303 )
304 .with_detail(json!({
305 "harness_id": harness.id,
306 "base": harness.base,
307 "unsupported": unsupported,
308 })));
309 }
310
311 let mut applied = Applied {
312 mcp_servers: servers
313 .into_iter()
314 .map(mcp_config)
315 .collect::<Result<_, _>>()?,
316 ..Applied::default()
317 };
318
319 if let Some(flag) = mechanism.disabled_tools {
320 if !harness.disabled_tools.is_empty() {
321 applied.args.push(flag.to_owned());
322 applied.args.extend(harness.disabled_tools.iter().cloned());
323 }
324 }
325
326 if let Some(flag) = mechanism.skills {
327 if !skills.is_empty() {
328 let staged = StagedSkills::write(files::staging_dir(task_id), &skills)?;
329 // An argument is a string; a staging path that is not one would
330 // reach the CLI mangled and load nothing, so it stops here.
331 let dir = staged.dir().to_str().ok_or_else(staging_failed)?.to_owned();
332 applied.args.push(flag.to_owned());
333 applied.args.push(dir);
334 applied.skills = Some(staged);
335 }
336 }
337
338 Ok(applied)
339}