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
);
}
}