Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ pub fn tools() -> Vec<Tool> {
"required": ["library", "template", "name"]
})),
),
Tool::new(
"deploy_provision_agent",
"Provision an agent directly from structured inputs — no compose library, no template ⊕ overlay (studio#128: the New Fleet wizard's vendor/chat-platform/ACP flow has no template to point at). config.toml is rendered server-side from these fields (the single source of truth — any caller, wizard or otherwise, that sends the same fields gets byte-identical config.toml, no drift between generators). Same create-vs-redeploy behavior as deploy_provision: patches an existing stored manifest's image/bundle if this agent already has one, otherwise builds a fresh manifest with sensible defaults. `provider` (default \"aws\") selects the target, same as deploy_provision. k8s deploys refuse a non-empty `chat_platform`: config.toml's secret resolution only supports aws-sm:// (AWS Secrets Manager), which a k8s pod has no credential chain to reach — ACP is the k8s connection path today.",
as_map(json!({
"type": "object",
"properties": {
"image": { "type": "string", "description": "Container image (e.g. ghcr.io/openabdev/openab:<tag>-<vendor>)." },
"name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." },
"namespace": { "type": "string", "description": "Namespace (default \"default\")." },
"api_key": { "type": "string", "description": "Optional vendor API key. Captured and stored as a secret; not yet wired into config.toml (which env var a given vendor's CLI expects it under needs vendor-specific follow-up)." },
"chat_platform": { "type": "string", "description": "Optional: \"discord\" | \"telegram\" | \"line\". Omit for no chat platform (connect via ACP directly). AWS only — refused for k8s deploys." },
"chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." },
"chat_channel_secret": { "type": "string", "description": "LINE only." },
"provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." },
"fleet": { "type": "string", "description": "AWS only. Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." },
"cluster": { "type": "string", "description": "AWS only. ECS cluster (defaults to the server's configured cluster)." },
"context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." },
"expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount:<namespace>:<name>` to set the pod's service account; unset uses the namespace's default." }
},
"required": ["image", "name"]
})),
),
Tool::new(
"deploy_delete",
"Delete a control-plane resource (e.g. an OABService).",
Expand Down Expand Up @@ -399,6 +421,7 @@ impl OabMcp {
"deploy_events" => self.t_events(args).await,
"deploy_apply" => self.t_apply(args).await,
"deploy_provision" => self.t_provision(args).await,
"deploy_provision_agent" => self.t_provision_agent(args).await,
"deploy_scale" => self.t_scale(args).await,
"deploy_delete" => self.t_delete(args).await,
"runtime_context" => self.t_runtime_context(args).await,
Expand Down Expand Up @@ -673,6 +696,87 @@ impl OabMcp {
}))
}

/// [`t_provision`], but for `deploy_provision_agent` (studio#128) — no
/// `library`/`template`/`overlay` args, `config_toml` is used as-is.
async fn t_provision_agent(&self, args: &Map<String, Value>) -> Result<Value> {
let namespace = args
.get("namespace")
.and_then(Value::as_str)
.unwrap_or("default");
let name = args
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?;
let image = args
.get("image")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("missing required arg: image"))?;
let input = scp::AgentWizardInput {
api_key: args.get("api_key").and_then(Value::as_str).map(str::to_string),
chat_platform: args.get("chat_platform").and_then(Value::as_str).map(str::to_string),
chat_bot_token: args.get("chat_bot_token").and_then(Value::as_str).map(str::to_string),
chat_channel_secret: args
.get("chat_channel_secret")
.and_then(Value::as_str)
.map(str::to_string),
};

if args.get("provider").and_then(Value::as_str) == Some("k8s") {
let context = args.get("context").and_then(Value::as_str);
let expected_principal = args.get("expected_principal").and_then(Value::as_str);
let outcome = scp::provision_agent_k8s(
&self.aws,
context,
namespace,
name,
image,
input,
expected_principal,
)
.await?;
return Ok(json!({
"ok": true,
"context": context,
"namespace": namespace,
"name": name,
"image": outcome.image,
"digest": outcome.digest,
"objects": outcome.objects,
"action": outcome.action,
"services_applied": outcome.services_applied,
}));
}

let t = self.target(args)?;
let cluster = t.cluster.clone();

let service_name = format!("oab-{namespace}-{name}");
if !t.includes(&service_name, name) {
anyhow::bail!("service {service_name:?} is not a member of the named fleet");
}

let outcome = scp::provision_agent(
&self.aws_for(&cluster).await,
&cluster,
namespace,
name,
image,
input,
)
.await?;
Ok(json!({
"ok": true,
"cluster": cluster,
"namespace": namespace,
"name": name,
"image": outcome.image,
"digest": outcome.digest,
"objects": outcome.objects,
"action": outcome.action,
"services_applied": outcome.services_applied,
}))
}

async fn t_apply(&self, args: &Map<String, Value>) -> Result<Value> {
let cluster = self.target(args)?.cluster;
let manifest = args
Expand Down Expand Up @@ -1005,14 +1109,15 @@ mod tests {
.iter()
.map(|t| t["name"].as_str().expect("tool has a name").to_string())
.collect();
assert_eq!(names.len(), 17);
assert_eq!(names.len(), 18);
for expected in [
"deploy_list",
"deploy_get",
"get_agent_states",
"deploy_events",
"deploy_apply",
"deploy_provision",
"deploy_provision_agent",
"deploy_scale",
"deploy_delete",
"runtime_context",
Expand Down
Loading
Loading