use std::path::PathBuf;
use serde_json::{json, Value};
use super::{detect, native, AppContext};
use crate::commands::apps::app_context_in;
pub struct CliAccountTools;
fn app_props() -> Value {
json!({
"appId": {"type": "string", "description": "Application id (app_…). Default: appId in offline.config.json in `dir`, then the CLI's saved application."},
"dir": {"type": "string", "description": "Project directory (default: the server's working directory)."}
})
}
fn tool(
name: &str,
description: &str,
mut props: Value,
required: &[&str],
read_only: bool,
) -> Value {
if let (Some(p), Some(app)) = (props.as_object_mut(), app_props().as_object()) {
for (k, v) in app {
p.entry(k.clone()).or_insert(v.clone());
}
}
json!({
"name": name,
"description": description,
"inputSchema": {"type": "object", "properties": props, "required": required, "additionalProperties": false},
"annotations": {
"readOnlyHint": read_only,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true,
},
})
}
pub fn definitions() -> Vec<Value> {
let mut defs = vec![
tool(
"list_applications",
"List the developer's Offline Protocol applications (id, name, organization).",
json!({}),
&[],
true,
),
tool(
"create_application",
"Create an application in the developer's organization (orgId, or the only/saved one). API keys are created with the CLI (`offline keys create`), which shows the secret once.",
json!({
"name": {"type": "string"},
"orgId": {"type": "string"},
"description": {"type": "string"}
}),
&["name"],
false,
),
tool(
"app_status",
"What the developer's application has set up and what is missing for Offline ID sign-in on iOS, Android, web and server, with the exact command or tool call that fixes each gap. Pass detect=true to compare with the native apps this project ships. Call this first, and again after changes.",
json!({"detect": {"type": "boolean", "description": "Also read app.json / Xcode / Gradle / assetlinks.json in `dir` and report unregistered identities."}}),
&[],
true,
),
tool(
"detect_native_apps",
"Read the iOS bundle IDs, Apple Team IDs, Android package names and signing-certificate SHA-256 fingerprints this project ships (app.json, Xcode, Gradle, assetlinks.json, the Android debug keystore). Changes nothing; needs no sign-in.",
json!({}),
&[],
true,
),
tool(
"register_native_app",
"Register a native app for app attestation so its users can sign in: iOS bundleId + teamId (Apple Team ID / App ID prefix), or Android package + sha256 (signing certificate fingerprints: Play App Signing key, upload key, debug key). Idempotent.",
json!({
"platform": {"type": "string", "enum": ["ios", "android"]},
"bundleId": {"type": "string"},
"teamId": {"type": "string"},
"package": {"type": "string"},
"sha256": {"type": "array", "items": {"type": "string"}}
}),
&["platform"],
false,
),
tool(
"remove_native_app",
"Remove a registered iOS bundle, an Android package, or one Android signing certificate. Native sign-ins from a removed app are refused.",
json!({
"platform": {"type": "string", "enum": ["ios", "android"]},
"bundleId": {"type": "string"},
"teamId": {"type": "string"},
"package": {"type": "string"},
"sha256": {"type": "string"}
}),
&["platform"],
false,
),
tool(
"set_development_builds",
"Accept (true) or refuse (false) App Attest proofs from iOS development builds (Xcode, `expo run:ios`). Turn on while developing.",
json!({"enabled": {"type": "boolean"}}),
&["enabled"],
false,
),
tool(
"set_allowed_origins",
"Allow (add) or stop allowing (remove) web origins that may sign in from a browser, e.g. https://example.com (http only for localhost).",
json!({
"add": {"type": "array", "items": {"type": "string"}},
"remove": {"type": "array", "items": {"type": "string"}}
}),
&[],
false,
),
tool(
"set_features",
"Turn product features on (enable) or off (disable): telemetry (Mesh SDK analytics), relay (hosted relay), proof-of-location.",
json!({
"enable": {"type": "array", "items": {"type": "string", "enum": ["telemetry", "relay", "proof-of-location"]}},
"disable": {"type": "array", "items": {"type": "string", "enum": ["telemetry", "relay", "proof-of-location"]}}
}),
&[],
false,
),
tool(
"apply_project_setup",
"Converge the application to the \"app\" block of offline.config.json (native apps, origins, features); with detect=true also register the native apps the project ships. Additive and idempotent; dryRun=true shows the plan only.",
json!({
"file": {"type": "string", "description": "Config file (default: offline.config.json in `dir`)."},
"detect": {"type": "boolean"},
"debugCert": {"type": "boolean", "description": "With detect, also register the Android debug keystore certificate."},
"dryRun": {"type": "boolean"}
}),
&[],
false,
),
tool(
"set_development_mode",
"Development mode for simulators, emulators and Expo Go, which cannot attest: enabled=true turns it on for `hours` (1 to 72, default 24; it ends by itself), enabled=false turns it off. While on, they sign in without attestation (email codes only) and their sessions are marked development and stop working when it ends. Refused for an app marked production.",
json!({
"enabled": {"type": "boolean"},
"hours": {"type": "integer", "minimum": 1, "maximum": 72}
}),
&["enabled"],
false,
),
tool(
"set_production",
"Mark the app production (enabled=true): development mode is then refused and ends. Unmark with enabled=false.",
json!({"enabled": {"type": "boolean"}}),
&["enabled"],
false,
),
tool(
"set_android_degraded",
"Android devices that cannot fully attest (a chain that ends at Google's attestation roots from a phone with an unlocked bootloader, an unverified boot, an expired or revoked factory certificate or a key held in software; or a phone that reports no key attestation): allow=true admits them with a lower, capped trust level (the default); allow=false refuses them, which locks those users out. A chain that does not end at Google's roots is refused either way.",
json!({"allow": {"type": "boolean"}}),
&["allow"],
false,
),
tool(
"attestation_history",
"Who turned development mode on or off, marked the app production, or changed the Android policy, and when (newest first).",
json!({"limit": {"type": "integer", "minimum": 1, "maximum": 200}}),
&[],
true,
),
tool(
"configure_native_project",
"Prepare the Expo app in `dir` for attestation: adds the App Attest entitlement to app.json (write=true; otherwise only reports) and returns the `npx expo install` command for @expo/app-integrity and @offline-protocol/id-react-native 0.4.0+ (it does not run the package manager). Needs no sign-in.",
json!({"write": {"type": "boolean"}}),
&[],
false,
),
tool(
"create_api_key",
"Create an API key for the app and write it to a gitignored env file in `dir` (default .env.local) as OFFLINE_API_KEY; the secret is never returned to the agent. Refused if the file is not ignored by git.",
json!({
"name": {"type": "string"},
"product": {"type": "string", "enum": ["all", "offline_id", "mesh_sdk", "proof_of_location"]},
"envFile": {"type": "string", "description": "Path relative to `dir` (default .env.local)."}
}),
&["name"],
false,
),
];
for d in defs
.iter_mut()
.filter(|d| d["name"] == "create_application" || d["name"] == "create_api_key")
{
d["annotations"]["idempotentHint"] = json!(false);
}
defs
}
fn ensure_ignored(dir: &std::path::Path, file: &str) -> anyhow::Result<std::path::PathBuf> {
let mut inside = dir.to_path_buf();
for component in std::path::Path::new(file).components() {
match component {
std::path::Component::Normal(part) => inside.push(part),
_ => anyhow::bail!("envFile must be a path inside the project directory"),
}
if std::fs::symlink_metadata(&inside).is_ok_and(|m| m.file_type().is_symlink()) {
anyhow::bail!("envFile must not go through a symbolic link");
}
}
let in_git = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if in_git {
let ignored = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["check-ignore", "-q"])
.arg(file)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ignored {
anyhow::bail!(
"{file} is not ignored by git, so the key would be committed: add it to .gitignore first (or pass another envFile that is ignored)"
);
}
}
Ok(dir.join(file))
}
fn write_env_key(
dir: &std::path::Path,
file: &str,
app_id: &str,
key: &str,
) -> anyhow::Result<std::path::PathBuf> {
let path = ensure_ignored(dir, file)?;
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = existing
.lines()
.filter(|l| !l.starts_with("OFFLINE_API_KEY=") && !l.starts_with("OFFLINE_APP_ID="))
.map(str::to_string)
.collect();
lines.push(format!("OFFLINE_APP_ID={app_id}"));
lines.push(format!("OFFLINE_API_KEY={key}"));
std::fs::write(&path, format!("{}\n", lines.join("\n")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(path)
}
fn s<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
}
fn list(args: &Value, key: &str) -> Vec<String> {
match args.get(key) {
Some(Value::Array(a)) => a
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
Some(Value::String(one)) => vec![one.clone()],
_ => Vec::new(),
}
}
fn dir_of(args: &Value) -> PathBuf {
s(args, "dir")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn explain(err: anyhow::Error) -> String {
let text = format!("{err:#}");
if text.contains("offline login --email") || text.contains("not logged in") {
return format!(
"{text}\n\nTo sign in from an agent: run `offline login --email <the developer's email>` (it sends a code), ask the developer for the code, then run `offline login --email <email> --code <code>`. Or set OFFLINE_ACCESS_TOKEN."
);
}
text
}
impl offline_mcp::AccountTools for CliAccountTools {
fn definitions(&self) -> Vec<Value> {
definitions()
}
fn call(&self, name: &str, args: &Value) -> Option<Result<Value, String>> {
if !definitions().iter().any(|d| d["name"] == name) {
return None;
}
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => return Some(Err(format!("could not start: {e}"))),
};
Some(runtime.block_on(run(name, args)).map_err(explain))
}
}
async fn ctx(args: &Value, what: &str) -> anyhow::Result<AppContext> {
app_context_in(s(args, "appId"), &dir_of(args), what).await
}
async fn run(name: &str, args: &Value) -> anyhow::Result<Value> {
match name {
"list_applications" | "create_application" => {
use crate::commands::provision::{client_from_config, developer_session};
let mut config = crate::config::CliConfig::load()?;
let token = developer_session(&mut config, "apps").await?;
let client = client_from_config(&config).await?;
if name == "list_applications" {
let apps = client.list_applications(&token).await?;
return Ok(json!({ "applications": apps }));
}
let app_name = s(args, "name").ok_or_else(|| anyhow::anyhow!("name is required"))?;
let org_id = match s(args, "orgId")
.map(str::to_string)
.or_else(|| config.effective_org_id())
{
Some(org) => org,
None => {
let orgs = client.list_organizations(&token).await?;
match orgs.as_slice() {
[one] => one.id.clone(),
[] => anyhow::bail!(
"no organization yet: run `offline setup --app-name \"{app_name}\"` (creates the organization, the application and an API key)"
),
many => anyhow::bail!(
"pass orgId, one of: {}",
many.iter().map(|o| format!("{} ({})", o.id, o.name)).collect::<Vec<_>>().join(", ")
),
}
}
};
let app = client
.create_application(&token, app_name, &org_id, s(args, "description"))
.await?;
Ok(json!({
"application": app,
"next": format!("call app_status with appId {} (or apply_project_setup with detect=true)", app.id),
}))
}
"configure_native_project" => Ok(serde_json::to_value(detect::setup_expo_project(
&dir_of(args),
args.get("write").and_then(Value::as_bool).unwrap_or(false),
))?),
"set_development_mode" => {
let enabled = args
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| anyhow::anyhow!("enabled must be true or false"))?;
let hours = args.get("hours").and_then(Value::as_u64).unwrap_or(24) as u32;
let c = ctx(args, "apps native dev-mode").await?;
let mode = c.set_development_mode(enabled.then_some(hours)).await?;
Ok(json!({ "appId": c.app_id, "developmentMode": mode }))
}
"set_production" | "set_android_degraded" => {
let key = if name == "set_production" {
"enabled"
} else {
"allow"
};
let on = args
.get(key)
.and_then(Value::as_bool)
.ok_or_else(|| anyhow::anyhow!("{key} must be true or false"))?;
let c = ctx(args, "apps native").await?;
let production = name == "set_production";
let (changed, _) = c
.edit_native(move |n| {
Ok(if production {
n.set_production(on)
} else {
n.set_android_degraded(on)
})
})
.await?;
Ok(json!({ "changed": changed, "status": c.status(None).await? }))
}
"attestation_history" => {
let c = ctx(args, "apps native history").await?;
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as u32;
Ok(json!({ "appId": c.app_id, "events": c.attestation_events(limit).await? }))
}
"create_api_key" => {
let c = ctx(args, "keys").await?;
let key_name = s(args, "name").ok_or_else(|| anyhow::anyhow!("name is required"))?;
let product = s(args, "product").unwrap_or("all");
let file = s(args, "envFile").unwrap_or(".env.local");
let dir = dir_of(args);
ensure_ignored(&dir, file)?;
let record = c
.client
.create_api_key(&c.token, &c.app_id, key_name, product, "full_access")
.await?;
let secret = record
.key
.as_deref()
.ok_or_else(|| anyhow::anyhow!("the server did not return the new key"))?;
let path = write_env_key(&dir, file, &c.app_id, secret)?;
Ok(json!({
"appId": c.app_id,
"keyId": record.id,
"name": record.name,
"product": record.product,
"envFile": path.display().to_string(),
"variables": ["OFFLINE_APP_ID", "OFFLINE_API_KEY"],
"note": "The key is in the env file only; it was not returned here.",
}))
}
"detect_native_apps" => Ok(serde_json::to_value(detect::detect(
&dir_of(args),
&detect::DetectOptions::default(),
))?),
"app_status" => {
let c = ctx(args, "apps status").await?;
let detected = args
.get("detect")
.and_then(Value::as_bool)
.unwrap_or(false)
.then(|| detect::detect(&dir_of(args), &detect::DetectOptions::default()));
c.status(detected.as_ref()).await
}
"register_native_app" | "remove_native_app" => {
let add = name == "register_native_app";
let c = ctx(args, "apps native").await?;
let platform = s(args, "platform").unwrap_or("");
let (changed, _) = match platform {
"ios" => {
let bundle = s(args, "bundleId")
.and_then(native::normalize_bundle_id)
.ok_or_else(|| anyhow::anyhow!("bundleId: {}", native::BUNDLE_ID_HELP))?;
let team = s(args, "teamId");
if add {
let team = team
.and_then(native::normalize_team_id)
.ok_or_else(|| anyhow::anyhow!("teamId: {}", native::TEAM_ID_HELP))?;
c.edit_native(move |n| Ok(n.add_ios(&bundle, &team)))
.await?
} else {
let team = team.map(str::to_string);
c.edit_native(move |n| Ok(n.remove_ios(&bundle, team.as_deref())))
.await?
}
}
"android" => {
let package = s(args, "package")
.and_then(native::normalize_package)
.ok_or_else(|| anyhow::anyhow!("package: {}", native::PACKAGE_HELP))?;
let certs = list(args, "sha256")
.iter()
.map(|c| {
native::normalize_sha256(c).ok_or_else(|| {
anyhow::anyhow!("sha256 `{c}`: {}", native::SHA256_HELP)
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
if add {
if certs.is_empty() {
anyhow::bail!("sha256: {}", native::SHA256_HELP);
}
c.edit_native(move |n| Ok(n.add_android(&package, &certs)))
.await?
} else {
let cert = certs.into_iter().next();
c.edit_native(move |n| Ok(n.remove_android(&package, cert.as_deref())))
.await?
}
}
_ => anyhow::bail!("platform must be \"ios\" or \"android\""),
};
Ok(json!({ "changed": changed, "status": c.status(None).await? }))
}
"set_development_builds" => {
let on = args
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| anyhow::anyhow!("enabled must be true or false"))?;
let c = ctx(args, "apps native dev-builds").await?;
let (changed, _) = c
.edit_native(move |n| Ok(n.set_allow_development(on)))
.await?;
Ok(json!({ "changed": changed, "allowDevelopmentBuilds": on }))
}
"set_allowed_origins" => {
let c = ctx(args, "apps origins").await?;
let (changed, origins) = c
.edit_origins(&list(args, "add"), &list(args, "remove"))
.await?;
Ok(json!({ "changed": changed, "origins": origins }))
}
"set_features" => {
let mut wanted = Vec::new();
for f in list(args, "enable") {
wanted.push((super::feature(&f)?, true));
}
for f in list(args, "disable") {
wanted.push((super::feature(&f)?, false));
}
let c = ctx(args, "apps features").await?;
let changed = c.set_features(&wanted).await?;
let features: serde_json::Map<String, Value> = c
.features()
.await?
.into_iter()
.map(|(f, on)| (f.name.to_string(), Value::Bool(on)))
.collect();
Ok(json!({ "changed": changed, "features": features }))
}
"apply_project_setup" => {
let dir = dir_of(args);
let file = s(args, "file")
.map(PathBuf::from)
.unwrap_or_else(|| dir.join("offline.config.json"));
let detect_on = args.get("detect").and_then(Value::as_bool).unwrap_or(false);
let config: Value = match std::fs::read_to_string(&file) {
Ok(text) => serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("{} is not valid JSON: {e}", file.display()))?,
Err(_) if detect_on => json!({}),
Err(_) => anyhow::bail!(
"{} not found: create it (`init_project`), add an \"app\" block, or pass detect=true",
file.display()
),
};
let mut desired = super::desired_from_config(&config);
let detected =
detect_on.then(|| detect::detect(&dir, &detect::DetectOptions::default()));
if let Some(found) = &detected {
super::merge_detected(
&mut desired,
found,
args.get("debugCert")
.and_then(Value::as_bool)
.unwrap_or(false),
);
}
let app_flag = s(args, "appId").map(str::to_string).or_else(|| {
config
.get("appId")
.and_then(Value::as_str)
.map(str::to_string)
});
let c = app_context_in(app_flag.as_deref(), &dir, "apply").await?;
let dry_run = args.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let changes = super::apply(&c, &desired, dry_run).await?;
Ok(json!({
"appId": c.app_id,
"dryRun": dry_run,
"changes": changes,
"detected": detected,
"status": c.status(detected.as_ref()).await?,
}))
}
other => anyhow::bail!("unknown tool {other}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_account_tool_has_a_schema_and_annotations() {
let defs = definitions();
assert_eq!(defs.len(), 16);
for d in &defs {
assert!(
d["inputSchema"]["properties"]["appId"].is_object(),
"{}",
d["name"]
);
let idempotent = d["name"] != "create_application" && d["name"] != "create_api_key";
assert_eq!(
d["annotations"]["idempotentHint"], idempotent,
"{}",
d["name"]
);
}
}
#[test]
fn detection_needs_no_sign_in() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.json"),
r#"{"expo":{"ios":{"bundleIdentifier":"com.example.app","appleTeamId":"ABCDE12345"}}}"#,
)
.unwrap();
let out = offline_mcp::AccountTools::call(
&CliAccountTools,
"detect_native_apps",
&json!({"dir": dir.path()}),
)
.unwrap()
.unwrap();
assert_eq!(out["ios"][0]["bundleId"], "com.example.app");
assert!(
offline_mcp::AccountTools::call(&CliAccountTools, "list_packages", &json!({}))
.is_none()
);
}
}
#[cfg(test)]
mod env_tests {
use super::write_env_key;
#[test]
fn the_key_goes_only_into_an_ignored_env_file() {
let dir = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(args)
.output()
.unwrap()
};
git(&["init", "-q"]);
assert!(write_env_key(dir.path(), ".env.local", "app_x", "secret-1").is_err());
assert!(!dir.path().join(".env.local").exists());
std::fs::write(dir.path().join(".gitignore"), ".env.local\n").unwrap();
std::fs::write(
dir.path().join(".env.local"),
"OTHER=1\nOFFLINE_API_KEY=old\n",
)
.unwrap();
write_env_key(dir.path(), ".env.local", "app_x", "secret-2").unwrap();
let text = std::fs::read_to_string(dir.path().join(".env.local")).unwrap();
assert_eq!(
text,
"OTHER=1\nOFFLINE_APP_ID=app_x\nOFFLINE_API_KEY=secret-2\n"
);
assert!(write_env_key(dir.path(), "../outside", "app_x", "s").is_err());
}
#[cfg(unix)]
#[test]
fn an_env_file_cannot_be_a_symbolic_link() {
let dir = tempfile::tempdir().unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let target = elsewhere.path().join("stolen");
std::fs::write(&target, "").unwrap();
std::os::unix::fs::symlink(&target, dir.path().join(".env.local")).unwrap();
assert!(write_env_key(dir.path(), ".env.local", "app_x", "secret").is_err());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
let linked = dir.path().join("config");
std::os::unix::fs::symlink(elsewhere.path(), &linked).unwrap();
assert!(write_env_key(dir.path(), "config/.env.local", "app_x", "secret").is_err());
assert!(!elsewhere.path().join(".env.local").exists());
}
}