use convert_case::{Case, Casing};
use indexmap::IndexMap;
use crate::core::{rendered_template::RenderedTemplate, watermark::apply_watermark};
use super::*;
pub(super) struct McpServer {
pub(super) name: String,
pub(super) url: String,
}
fn valid_tool_prefix(name: &str) -> bool {
let mut bytes = name.bytes();
bytes.next().is_some_and(|b| b.is_ascii_lowercase())
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
&& name.len() <= 32
}
pub(super) fn setting(name: &str, suffix: &str) -> String {
format!("{}_{suffix}", name.replace('-', "_").to_ascii_uppercase())
}
pub(super) fn mcp_servers(matches: &ArgMatches) -> Result<Vec<McpServer>> {
let mut servers: Vec<McpServer> = Vec::new();
for value in matches.get_many::<String>("mcp_servers").into_iter().flatten() {
let (name, url) = value
.split_once('=')
.context("--mcp expects <name>=<url>, for example --mcp linear=https://mcp.linear.app/mcp")?;
anyhow::ensure!(
valid_tool_prefix(name),
"MCP server name {name:?} must match ^[a-z][a-z0-9_]*$ (it prefixes tool names)"
);
let parsed = url::Url::parse(url).with_context(|| format!("Invalid MCP server URL for {name}"))?;
anyhow::ensure!(
matches!(parsed.scheme(), "https" | "http") && parsed.username().is_empty() && parsed.password().is_none(),
"MCP server {name} needs an http(s) URL without credentials; put tokens in {}",
setting(name, "MCP_TOKEN")
);
anyhow::ensure!(!servers.iter().any(|s| s.name == name), "Duplicate MCP server {name}");
servers.push(McpServer { name: name.to_owned(), url: url.to_owned() });
}
Ok(servers)
}
pub(super) fn services(matches: &ArgMatches, app_root: &Path) -> Result<Vec<(String, String)>> {
let manifest = read_manifest_value(&app_root.join(".forklaunch/manifest.toml"))?;
let compose = read_or_create_rust_docker_compose(&app_root.join("docker-compose.yaml"))?;
let mut resolved = Vec::new();
for name in matches.get_many::<String>("services").into_iter().flatten() {
let project = project_table(&manifest, name).with_context(|| format!("No project named {name}"))?;
anyhow::ensure!(
project.get("type").and_then(Value::as_str) == Some("Service"),
"--service {name} must name a service project"
);
anyhow::ensure!(valid_tool_prefix(name), "Service {name} cannot prefix tool names; rename it to snake_case");
let port = compose
.services
.get(name)
.and_then(|service| service.environment.as_ref())
.and_then(|env| env.get("PORT"))
.with_context(|| format!("Service {name} has no PORT in docker-compose.yaml"))?;
resolved.push((name.clone(), format!("http://{name}:{port}")));
}
Ok(resolved)
}
pub(super) fn compose(
app_root: &Path,
name: &str,
services: &[(String, String)],
servers: &[McpServer],
) -> Result<()> {
let path = app_root.join("docker-compose.yaml");
let mut compose = read_or_create_rust_docker_compose(&path)?;
let service = compose.services.get_mut(name).context("Expected agent Compose service")?;
let env = service.environment.get_or_insert_with(IndexMap::new);
let mut set = |key: String, value: String| {
env.entry(key).or_insert(value);
};
let prefix = name.to_case(Case::UpperSnake);
set("HMAC_SECRET_KEY".into(), format!("${{{prefix}_HMAC_SECRET_KEY:?Configure the agent continuation secret}}"));
set("JWKS_PUBLIC_KEY_URL".into(), format!("${{{prefix}_JWKS_PUBLIC_KEY_URL:?Configure trusted JWKS}}"));
for key in ["AGENT_MODEL", "PLATFORM_GATEWAY_URL", "INSTANCE_ID", "INSTANCE_HMAC_KEY", "FORKLAUNCH_MODEL_STREAMING"] {
set(key.into(), format!("${{{key}:-}}"));
}
for (service, url) in services {
set(setting(service, "URL"), url.clone());
}
for server in servers {
set(setting(&server.name, "MCP_URL"), server.url.clone());
let token = setting(&server.name, "MCP_TOKEN");
set(token.clone(), format!("${{{token}:-}}"));
}
write(&path, yaml_to_string(&compose)?)?;
Ok(())
}
pub(super) fn mark_variant(app_root: &Path, name: &str) -> Result<()> {
let path = app_root.join(".forklaunch/manifest.toml");
let mut manifest = read_manifest_value(&path)?;
project_table_mut(&mut manifest, name)
.context("Expected generated agent project")?
.insert("variant".into(), Value::String("agent".into()));
write_manifest_value(&path, &manifest)
}
const DEFAULT_INSTRUCTIONS: &str = "You are a careful assistant for this application. Answer from the tools you have, say when you do not know, and do not change data unless the user asks you to.";
#[derive(Content)]
struct AgentTemplate {
name: String,
crate_module: String,
instructions: String,
has_services: bool,
services: Vec<ServiceSource>,
has_servers: bool,
servers: Vec<ServerSource>,
}
#[derive(Content)]
struct ServiceSource {
service: String,
}
#[derive(Content)]
struct ServerSource {
server: String,
url_setting: String,
token_setting: String,
}
const BINDING_SOURCE: &str = "agent/mod.rs";
pub(super) fn host_with_agent(host: &str) -> Result<String> {
if host.contains("agent::contract::routes()") {
return Ok(host.to_owned());
}
let mut parts = host.splitn(3, "Vec::new();");
let (Some(routes), Some(declarations), Some(rest)) = (parts.next(), parts.next(), parts.next())
else {
bail!("Unexpected contracts host layout");
};
Ok(format!(
"{routes}Vec::new();routes.extend(forklaunch_core::agent::contract::routes());{declarations}Vec::new();declarations.extend(forklaunch_core::agent::contract::bindings(\"{BINDING_SOURCE}\")?);{rest}"
))
}
pub(super) fn routes_with_agent(source: &str) -> Result<String> {
if source.contains("agent::mount(") {
return Ok(source.to_owned());
}
let at = source.rfind(".finish()").context("Unexpected api/routes/mod.rs layout")?;
let head = &source[..at];
let statement_start = head.rfind('{').context("Unexpected api/routes/mod.rs layout")? + 1;
let (before, body) = head.split_at(statement_start);
let trimmed = body.trim_start();
let leading = &body[..body.len() - trimmed.len()];
let (bindings, expression) = trimmed.rsplit_once(';').map_or(("", trimmed), |(b, e)| (b, e));
let bindings = if bindings.is_empty() { String::new() } else { format!("{bindings};") };
Ok(format!(
"{before}{leading}{bindings} agent::mount({})?{}",
expression.trim(),
&source[at..]
)
.replacen("use crate::registrations::AppState;", "use crate::{agent, registrations::AppState};", 1))
}
fn edit_features(cargo: &mut toml_edit::DocumentMut, table: &str, dependency: &str, features: &[&str]) -> Result<()> {
let entry = cargo[table][dependency]
.as_table_like_mut()
.with_context(|| format!("Expected {dependency} in [{table}]"))?;
let list = entry
.entry("features")
.or_insert(toml_edit::value(toml_edit::Array::new()))
.as_array_mut()
.with_context(|| format!("Expected a features list for {dependency}"))?;
for feature in features {
if !list.iter().any(|value| value.as_str() == Some(feature)) {
list.push(*feature);
}
}
list.fmt();
Ok(())
}
fn patch(path: &Path, edit: impl FnOnce(&str) -> Result<String>) -> Result<()> {
let source = read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let next = edit(&source)?;
if next != source {
write(path, native_layout::format_source(path, &next))
.with_context(|| format!("Failed to write {}", path.display()))?;
}
Ok(())
}
fn insert_after(source: &str, anchor: &str, text: &str, file: &str) -> Result<String> {
if source.contains(text.trim()) {
return Ok(source.to_owned());
}
let at = source
.find(anchor)
.with_context(|| format!("Unexpected {file} layout: missing `{anchor}`"))?
+ anchor.len();
Ok(format!("{}{text}{}", &source[..at], &source[at..]))
}
pub(super) fn sources(
app_root: &Path,
name: &str,
instructions: Option<&str>,
services: &[(String, String)],
servers: &[McpServer],
) -> Result<()> {
let manifest = read_manifest_value(&app_root.join(".forklaunch/manifest.toml"))?;
let crate_path = project_table(&manifest, name)
.and_then(|project| project.get("metadata"))
.and_then(|metadata| metadata.get("cratePath"))
.and_then(Value::as_str)
.map_or_else(|| format!("src/{name}"), str::to_owned);
let dir = app_root.join(crate_path);
let crate_module = name.to_case(Case::Snake);
let data = AgentTemplate {
name: name.to_owned(),
crate_module: crate_module.clone(),
instructions: format!("{:?}", instructions.map_or(DEFAULT_INSTRUCTIONS, str::trim)),
has_services: !services.is_empty(),
services: services
.iter()
.map(|(service, _)| ServiceSource { service: service.clone() })
.collect(),
has_servers: !servers.is_empty(),
servers: servers
.iter()
.map(|server| ServerSource {
server: server.name.clone(),
url_setting: setting(&server.name, "MCP_URL"),
token_setting: setting(&server.name, "MCP_TOKEN"),
})
.collect(),
};
let mut stdout = StandardStream::stdout(ColorChoice::Never);
for (template, output) in [
("rust/agent/mod.rs", "agent/mod.rs"),
("rust/agent/tools/mod.rs", "agent/tools/mod.rs"),
("rust/agent/tools/current_time.rs", "agent/tools/current_time.rs"),
("rust/agent/__test__/agent.rs", "__test__/agent.rs"),
] {
let path = dir.join(output);
let content = apply_watermark(&RenderedTemplate {
path: path.clone(),
content: render_rust_template(template, &data),
context: None,
})?;
write_text_file(&path, &content, false, &mut stdout)?;
}
patch(&dir.join("lib.rs"), |source| insert_before_line(source, "pub mod api;", "pub mod agent;\n", "lib.rs"))?;
patch(&dir.join("registrations.rs"), |source| {
let source = insert_after(source, "pub struct AppState {\n", " pub agent: AgentRuntime,\n", "registrations.rs")?;
let source = if source.contains("Ok(AppState {\n") {
insert_after(&source, "Ok(AppState {\n", " agent: AgentRuntime::from_env()?,\n", "registrations.rs")?
} else {
insert_after(&source, "Ok(AppState { ", "agent: AgentRuntime::from_env()?, ", "registrations.rs")?
};
insert_before_line(&source, "use ", "use crate::agent::AgentRuntime;\n", "registrations.rs")
})?;
patch(&dir.join("bootstrapper.rs"), |source| {
insert_after(
source,
"let state = registrations::register(config)?;\n",
" // Fail fast on tool-name collisions.\n state.agent.check().await?;\n",
"bootstrapper.rs",
)
})?;
patch(&dir.join("api/routes/mod.rs"), routes_with_agent)?;
let host = contracts_layout::new_host_directory(&dir);
patch(&host.join("lib.rs"), host_with_agent)?;
for (path, edits) in [
(dir.join("Cargo.toml"), vec![
("dependencies", "forklaunch-core", vec!["agent"]),
("dependencies", "forklaunch-axum", vec!["agent"]),
]),
(host.join("Cargo.toml"), vec![("dependencies", "forklaunch-core", vec!["agent-contract"])]),
] {
let original = read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let mut cargo: toml_edit::DocumentMut = original.parse()?;
for (table, dependency, features) in edits {
edit_features(&mut cargo, table, dependency, &features)?;
}
if path == dir.join("Cargo.toml") {
let tests = cargo
.entry("test")
.or_insert(toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
.as_array_of_tables_mut()
.context("Expected [[test]] tables")?;
if !tests.iter().any(|test| test.get("name").and_then(|n| n.as_str()) == Some("agent")) {
let mut test = toml_edit::Table::new();
test["name"] = toml_edit::value("agent");
test["path"] = toml_edit::value("__test__/agent.rs");
tests.push(test);
}
}
write(&path, cargo.to_string())?;
}
Ok(())
}
fn insert_before_line(source: &str, prefix: &str, text: &str, file: &str) -> Result<String> {
if source.lines().any(|line| line == text.trim_end()) {
return Ok(source.to_owned());
}
let mut offset = 0;
for line in source.split_inclusive('\n') {
if line.starts_with(prefix) {
return Ok(format!("{}{text}{}", &source[..offset], &source[offset..]));
}
offset += line.len();
}
bail!("Unexpected {file} layout: no line starting with `{prefix}`")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_and_prefixes_follow_the_shared_contract() {
assert_eq!(setting("records", "URL"), "RECORDS_URL");
assert_eq!(setting("linear", "MCP_TOKEN"), "LINEAR_MCP_TOKEN");
assert_eq!(setting("records2", "URL"), "RECORDS2_URL");
assert_eq!(setting("case_notes", "URL"), "CASE_NOTES_URL");
assert!(valid_tool_prefix("records"));
assert!(!valid_tool_prefix("Records"));
assert!(!valid_tool_prefix("my-server"));
}
#[test]
fn agent_routes_join_the_contracts_host_and_router() {
let host = super::super::contracts_layout::host_source("assistant", &["health".into()]);
let joined = host_with_agent(&host).unwrap();
assert!(joined.contains("routes.extend(forklaunch_core::agent::contract::routes());"));
assert!(joined.contains("forklaunch_core::agent::contract::bindings(\"agent/mod.rs\")?"));
assert_eq!(host_with_agent(&joined).unwrap(), joined);
let generated = "use crate::registrations::AppState;\npub fn router() -> Result<Router<AppState>, String> {\n health::mount(ContractRouter::new(contracts::registry()))?.finish()\n}\n";
let mounted = routes_with_agent(generated).unwrap();
assert!(mounted.contains("agent::mount(health::mount(ContractRouter::new(contracts::registry()))?)?.finish()"));
assert!(mounted.contains("use crate::{agent, registrations::AppState};"));
let regenerated = "use crate::registrations::AppState;\npub fn router() -> Result<Router<AppState>, String> { let router = ContractRouter::new(contracts::registry()); let router = health::mount(router)?;\n router.finish() }\n";
assert!(routes_with_agent(regenerated).unwrap().contains("agent::mount(router)?.finish()"));
assert_eq!(routes_with_agent(&mounted).unwrap(), mounted);
}
#[test]
fn imports_are_inserted_once_before_the_first_use() {
let source = "/*\n * header\n */\n\nuse crate::config::ServiceConfig;\n";
let once = insert_before_line(source, "use ", "use crate::agent::AgentRuntime;\n", "x").unwrap();
assert!(once.contains("*/\n\nuse crate::agent::AgentRuntime;\nuse crate::config"));
assert_eq!(insert_before_line(&once, "use ", "use crate::agent::AgentRuntime;\n", "x").unwrap(), once);
}
}