Skip to main content

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}