- 15001
event: String, - 15002
#[serde(default)] - 15003
matcher: Option<String>, - 15004
command: String, - 15005
#[serde(default)] - 15006
timeout_ms: Option<u64>, - 15007
#[serde(default = "default_hook_enabled")] - 15008
enabled: bool, - 15009
#[serde(default)] - 15010
failure_mode: Option<String>, - 15011
} - 15012
- 15013
fn default_hook_enabled() -> bool { - 15014
true - 15015
} - 15016
- 15017
#[derive(serde::Deserialize)] - 15018
struct HooksPutBody { - 15019
hooks: Vec<HookInput>, - 15020
#[serde(default)] - 15021
agent: Option<String>, - 15022
} - 15023
- 15024
/// Project-only, deliberately not `state.core.config().hooks` (the merged - 15025
/// effective list, global layer included — `vak_config::merge_into` extends - 15026
/// the project's hooks with the global ones on every load). `PUT - 15027
/// /config/hooks` replaces the *project* file's own `[[hooks]]` array with - 15028
/// whatever this endpoint reported; reporting the merged list would hand - 15029
/// back an inherited global hook, which the next save would then write into - 15030
/// the project file as if it were the project's own — duplicating it there, - 15031
/// and compounding on every subsequent edit as the merge re-extends over an - 15032
/// already-doubled list. Mirrors `get_global_hooks`, which has always read - 15033
/// its own file directly for the same reason. - 15034
/// Prompt layers (docs/design/45-prompt-layers.md). - 15035
/// - 15036
/// `GET /config/prompts?scope=` reports **only** the selected layer's own - 15037
/// text, never the merged view — AGENTS.md rule 21: this GET seeds a - 15038
/// same-shape PUT, and returning inherited text would write a parent layer's - 15039
/// content into the child file on the next save. The effective composition - 15040
/// is a separate endpoint on purpose. - 15041
async fn get_prompt_layer( - 15042
State(state): State<AppState>, - 15043
Query(query): Query<ScopeQuery>, - 15044
) -> axum::response::Response { - 15045
use axum::response::IntoResponse; - 15046
let core = scoped_core!(&state, None, query.agent.as_deref()); - 15047
let dir = vak_core::prompts::layer_dir(&query.scope.prompt_root(&core)); - 15048
let content = vak_core::prompts::read_layer(&dir); - 15049
Json(serde_json::json!({ - 15050
"scope": query.scope.label(), - 15051
"path": dir.display().to_string(), - 15052
"layer": content, - 15053
})) - 15054
.into_response() - 15055
} - 15056
- 15057
#[derive(serde::Deserialize)] - 15058
struct PromptBlockBody { - 15059
scope: ConfigScope, - 15060
block: String, - 15061
/// Absent or null resets the block and resumes inheritance. - 15062
#[serde(default)] - 15063
text: Option<String>, - 15064
#[serde(default)] - 15065
agent: Option<String>, - 15066
} - 15067
- 15068
async fn put_prompt_block( - 15069
State(state): State<AppState>, - 15070
Json(body): Json<PromptBlockBody>, - 15071
) -> axum::response::Response { - 15072
use axum::response::IntoResponse; - 15073
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15074
let Some(block) = vak_core::prompts::PromptBlock::parse(&body.block) else { - 15075
return ( - 15076
StatusCode::BAD_REQUEST, - 15077
Json(serde_json::json!({ - 15078
"error": format!("unknown block '{}'", body.block), - 15079
"editable": ["identity", "operating-rules", "guardrails"], - 15080
"note": "the capability contract, Surface line, and skill/MCP lists are code-owned", - 15081
})), - 15082
) - 15083
.into_response(); - 15084
}; - 15085
// A prompt is spent on every turn of every session, so an unbounded - 15086
// editor is a permanent context tax rather than a one-off mistake. - 15087
const MAX_BLOCK_BYTES: usize = 24_000; - 15088
if let Some(text) = body.text.as_deref() - 15089
&& text.len() > MAX_BLOCK_BYTES - 15090
{ - 15091
return ( - 15092
StatusCode::PAYLOAD_TOO_LARGE, - 15093
Json(serde_json::json!({ - 15094
"error": format!("{} is {}B; the cap is {MAX_BLOCK_BYTES}B", block.slug(), text.len()), - 15095
})), - 15096
) - 15097
.into_response(); - 15098
} - 15099
let dir = vak_core::prompts::layer_dir(&body.scope.prompt_root(&core)); - 15100
match vak_core::prompts::write_block(&dir, block, body.text.as_deref()) { - 15101
Ok(()) => Json(serde_json::json!({ - 15102
"ok": true, - 15103
"scope": body.scope.label(), - 15104
"block": block.slug(), - 15105
"reset": body.text.is_none(), - 15106
// Prompts freeze into the session contract, so the UI must not - 15107
// imply a running turn changed under the user. - 15108
"applies": "new sessions", - 15109
})) - 15110
.into_response(), - 15111
Err(error) => ( - 15112
StatusCode::INTERNAL_SERVER_ERROR, - 15113
Json(serde_json::json!({ "error": error.to_string() })), - 15114
) - 15115
.into_response(), - 15116
} - 15117
} - 15118
- 15119
/// The assembled prompt plus per-layer provenance — the right-hand pane. - 15120
async fn get_prompt_effective( - 15121
State(state): State<AppState>, - 15122
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15123
) -> axum::response::Response { - 15124
use axum::response::IntoResponse; - 15125
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15126
Json(prompt_effective_payload(&core)).into_response() - 15127
} - 15128
- 15129
async fn get_agents( - 15130
State(state): State<AppState>, - 15131
axum::extract::Query(query): axum::extract::Query<HashMap<String, String>>, - 15132
) -> axum::response::Response { - 15133
use axum::response::IntoResponse; - 15134
let root = match query - 15135
.get("scope") - 15136
.map(String::as_str) - 15137
.unwrap_or("workspace") - 15138
{ - 15139
"user" => vak_config::paths::default_workspace(), - 15140
"workspace" => state.active_core().cwd().clone(), - 15141
_ => { - 15142
return ( - 15143
StatusCode::BAD_REQUEST, - 15144
Json(serde_json::json!({"error": "invalid agent scope"})), - 15145
) - 15146
.into_response(); - 15147
} - 15148
}; - 15149
match agents::load(&root) { - 15150
Ok(agents) => Json(serde_json::json!({ "agents": agents })).into_response(), - 15151
Err(error) => ( - 15152
StatusCode::INTERNAL_SERVER_ERROR, - 15153
Json(serde_json::json!({ "error": error })), - 15154
) - 15155
.into_response(), - 15156
} - 15157
} - 15158
- 15159
async fn list_agent_templates() -> axum::response::Response { - 15160
use axum::response::IntoResponse; - 15161
Json(serde_json::json!({ - 15162
"templates": agents::builtin_templates() - 15163
})) - 15164
.into_response() - 15165
} - 15166
- 15167
#[derive(serde::Deserialize)] - 15168
struct InstantiateTemplateRequest { - 15169
template_id: String, - 15170
agent_id: String, - 15171
#[serde(default)] - 15172
name: Option<String>, - 15173
#[serde(default)] - 15174
scope: Option<String>, - 15175
} - 15176
- 15177
async fn instantiate_agent_template( - 15178
State(state): State<AppState>, - 15179
Json(body): Json<InstantiateTemplateRequest>, - 15180
) -> axum::response::Response { - 15181
use axum::response::IntoResponse; - 15182
let Some(template) = agents::find_template(&body.template_id) else { - 15183
return ( - 15184
StatusCode::NOT_FOUND, - 15185
Json(serde_json::json!({ "error": format!("template '{}' not found", body.template_id) })), - 15186
) - 15187
.into_response(); - 15188
}; - 15189
- 15190
let new_agent = template.to_agent_definition(&body.agent_id, body.name.as_deref()); - 15191
let root = match body.scope.as_deref().unwrap_or("workspace") { - 15192
"user" => vak_config::paths::default_workspace(), - 15193
_ => state.active_core().cwd().clone(), - 15194
}; - 15195
- 15196
let mut existing = agents::load(&root).unwrap_or_default(); - 15197
if existing.iter().any(|a| a.id == new_agent.id) { - 15198
return ( - 15199
StatusCode::CONFLICT, - 15200
Json( - 15201
serde_json::json!({ "error": format!("agent '{}' already exists", new_agent.id) }), - 15202
), - 15203
) - 15204
.into_response(); - 15205
} - 15206
existing.push(new_agent.clone()); - 15207
match agents::save( - 15208
&root, - 15209
&existing, - 15210
state.active_core().project_config_trusted(), - 15211
) { - 15212
Ok(_) => ( - 15213
StatusCode::CREATED, - 15214
Json(serde_json::json!({ "created": true, "agent": new_agent })), - 15215
) - 15216
.into_response(), - 15217
Err(err) => ( - 15218
StatusCode::BAD_REQUEST, - 15219
Json(serde_json::json!({ "error": err })), - 15220
) - 15221
.into_response(), - 15222
} - 15223
} - 15224
- 15225
#[derive(serde::Deserialize)] - 15226
struct CanvasPreviewRequest { - 15227
#[serde(default)] - 15228
title: Option<String>, - 15229
content: String, - 15230
} - 15231
- 15232
async fn canvas_preview(Json(body): Json<CanvasPreviewRequest>) -> axum::response::Response { - 15233
let title = body.title.as_deref().unwrap_or("Outcome Canvas"); - 15234
let html = vak_presentation::transcode_to_html(title, &body.content); - 15235
html_response(html) - 15236
} - 15237
- 15238
async fn put_agents( - 15239
State(state): State<AppState>, - 15240
Json(body): Json<serde_json::Value>, - 15241
) -> axum::response::Response { - 15242
use axum::response::IntoResponse; - 15243
let Some(raw) = body.get("agents") else { - 15244
return ( - 15245
StatusCode::BAD_REQUEST, - 15246
Json(serde_json::json!({ "error": "agents is required" })), - 15247
) - 15248
.into_response(); - 15249
}; - 15250
let Ok(agents) = serde_json::from_value::<Vec<agents::AgentDefinition>>(raw.clone()) else { - 15251
return ( - 15252
StatusCode::BAD_REQUEST, - 15253
Json(serde_json::json!({ "error": "invalid agents" })), - 15254
) - 15255
.into_response(); - 15256
}; - 15257
let root = match body - 15258
.get("scope") - 15259
.and_then(|v| v.as_str()) - 15260
.unwrap_or("workspace") - 15261
{ - 15262
"user" => vak_config::paths::default_workspace(), - 15263
"workspace" => state.active_core().cwd().clone(), - 15264
_ => { - 15265
return ( - 15266
StatusCode::BAD_REQUEST, - 15267
Json(serde_json::json!({"error": "invalid agent scope"})), - 15268
) - 15269
.into_response(); - 15270
} - 15271
}; - 15272
match agents::save(&root, &agents, state.active_core().project_config_trusted()) { - 15273
Ok(saved_agents) => { - 15274
Json(serde_json::json!({ "saved": true, "agents": saved_agents })).into_response() - 15275
} - 15276
Err(error) => ( - 15277
StatusCode::BAD_REQUEST, - 15278
Json(serde_json::json!({ "error": error })), - 15279
) - 15280
.into_response(), - 15281
} - 15282
} - 15283
- 15284
fn prompt_effective_payload(core: &vak_core::Core) -> serde_json::Value { - 15285
let resolution = core.resolve_prompt(&core.capability_descriptors()); - 15286
let seed_content = vak_core::prompts::seed(vak_core::APP_VERSION).content; - 15287
let seed_blocks = serde_json::json!({ - 15288
"identity": seed_content.identity, - 15289
"operating-rules": seed_content.operating_rules, - 15290
"guardrails": seed_content.guardrails.iter().map(|r| format!("- {r}")).collect::<Vec<_>>().join("\n"), - 15291
"surface-note": seed_content.surface_notes.iter().map(|r| format!("- {r}")).collect::<Vec<_>>().join("\n"), - 15292
}); - 15293
serde_json::json!({ - 15294
"text": resolution.text, - 15295
"fingerprint": resolution.fingerprint(), - 15296
"estimated_tokens": resolution.text.len() / 4, - 15297
"surface": core.surface().slug(), - 15298
"layers": resolution.descriptors, - 15299
"blocks": resolution.blocks, - 15300
"seed_blocks": seed_blocks, - 15301
}) - 15302
} - 15303
- 15304
#[derive(serde::Deserialize)] - 15305
struct PromptPreviewBody { - 15306
#[serde(default)] - 15307
surface: Option<String>, - 15308
#[serde(default)] - 15309
role: Option<String>, - 15310
#[serde(default)] - 15311
agent: Option<String>, - 15312
} - 15313
- 15314
/// Render exactly what a chosen surface and role would receive. Composition - 15315
/// across seven tiers is not guessable, so an editor without this is asking - 15316
/// the operator to simulate the resolver in their head. - 15317
async fn preview_prompt( - 15318
State(state): State<AppState>, - 15319
Json(body): Json<PromptPreviewBody>, - 15320
) -> axum::response::Response { - 15321
use axum::response::IntoResponse; - 15322
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15323
let raw_surface = body.surface.as_deref().map(str::trim).unwrap_or(""); - 15324
let surface = match raw_surface.to_ascii_lowercase().as_str() { - 15325
"" | "unknown" => vak_core::Surface::Unknown, - 15326
"cli" => vak_core::Surface::Cli, - 15327
"terminal" => vak_core::Surface::Terminal, - 15328
"desktop" => vak_core::Surface::Desktop, - 15329
"server" => vak_core::Surface::Server, - 15330
"web" => vak_core::Surface::Web, - 15331
"background" => vak_core::Surface::Background, - 15332
// "subagent" is kept for admin-console requests built against the - 15333
// pre-rename surface name. - 15334
"worker" | "subagent" => vak_core::Surface::Worker, - 15335
_ => vak_core::Surface::Chat { - 15336
channel: raw_surface.to_string(), - 15337
}, - 15338
}; - 15339
if let Some(role) = body.role.as_deref() - 15340
&& !core.prompt_role_names().iter().any(|n| n == role) - 15341
{ - 15342
return ( - 15343
StatusCode::BAD_REQUEST, - 15344
Json(serde_json::json!({ "error": format!("no role '{role}'") })), - 15345
) - 15346
.into_response(); - 15347
} - 15348
let core = core.with_surface(surface).with_prompt_role(body.role); - 15349
Json(prompt_effective_payload(&core)).into_response() - 15350
} - 15351
- 15352
async fn list_prompt_roles( - 15353
State(state): State<AppState>, - 15354
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15355
) -> axum::response::Response { - 15356
use axum::response::IntoResponse; - 15357
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15358
Json(serde_json::json!({ "roles": core.prompt_role_names() })).into_response() - 15359
} - 15360
- 15361
async fn get_hooks( - 15362
State(state): State<AppState>, - 15363
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15364
) -> axum::response::Response { - 15365
use axum::response::IntoResponse; - 15366
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15367
let path = core.cwd().join(".vak/config.toml"); - 15368
let hooks = if path.is_file() { - 15369
match std::fs::read_to_string(&path) - 15370
.ok() - 15371
.and_then(|raw| toml::from_str::<vak_config::FileConfig>(&raw).ok()) - 15372
{ - 15373
Some(config) => config.hooks, - 15374
None => { - 15375
return (StatusCode::BAD_REQUEST, "workspace config is invalid").into_response(); - 15376
} - 15377
} - 15378
} else { - 15379
Vec::new() - 15380
}; - 15381
let hooks = hooks - 15382
.iter() - 15383
.map(|h| { - 15384
serde_json::json!({ - 15385
"event": h.event, - 15386
"matcher": h.matcher, - 15387
"command": h.command, - 15388
"timeout_ms": h.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS), - 15389
"enabled": h.enabled, - 15390
"failure_mode": h.failure_mode.as_deref().unwrap_or("open"), - 15391
}) - 15392
}) - 15393
.collect::<Vec<_>>(); - 15394
Json(serde_json::json!({ "hooks": hooks })).into_response() - 15395
} - 15396
- 15397
async fn get_global_hooks() -> axum::response::Response { - 15398
use axum::response::IntoResponse; - 15399
let Some(path) = vak_config::global_path() else { - 15400
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 15401
}; - 15402
let hooks = if path.is_file() { - 15403
match std::fs::read_to_string(&path) - 15404
.ok() - 15405
.and_then(|raw| toml::from_str::<vak_config::FileConfig>(&raw).ok()) - 15406
{ - 15407
Some(config) => config.hooks, - 15408
None => return (StatusCode::BAD_REQUEST, "user config is invalid").into_response(), - 15409
} - 15410
} else { - 15411
Vec::new() - 15412
}; - 15413
Json(serde_json::json!({ "scope": "global", "hooks": hooks.into_iter().map(|h| serde_json::json!({ "event": h.event, "matcher": h.matcher, "command": h.command, "timeout_ms": h.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS), "enabled": h.enabled, "failure_mode": h.failure_mode.as_deref().unwrap_or("open") })).collect::<Vec<_>>() })).into_response() - 15414
} - 15415
- 15416
async fn put_global_hooks( - 15417
State(state): State<AppState>, - 15418
Json(body): Json<HooksPutBody>, - 15419
) -> axum::response::Response { - 15420
use axum::response::IntoResponse; - 15421
let Some(path) = vak_config::global_path() else { - 15422
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 15423
}; - 15424
let hooks = match validated_hook_configs(&body.hooks) { - 15425
Ok(hooks) => hooks, - 15426
Err(message) => { - 15427
return ( - 15428
StatusCode::BAD_REQUEST, - 15429
Json(serde_json::json!({ "error": message })), - 15430
) - 15431
.into_response(); - 15432
} - 15433
}; - 15434
if let Err(error) = vak_config::persist_hooks(&path, &hooks) { - 15435
return ( - 15436
StatusCode::INTERNAL_SERVER_ERROR, - 15437
Json(serde_json::json!({ "error": error.to_string() })), - 15438
) - 15439
.into_response(); - 15440
} - 15441
if state.core.refresh_persisted_preferences().is_err() { - 15442
return ( - 15443
StatusCode::INTERNAL_SERVER_ERROR, - 15444
"could not apply user hooks", - 15445
) - 15446
.into_response(); - 15447
} - 15448
Json(serde_json::json!({ "saved": true, "scope": "global", "count": hooks.len() })) - 15449
.into_response() - 15450
} - 15451
- 15452
fn validated_hook_configs(hooks: &[HookInput]) -> Result<Vec<vak_config::HookConfig>, String> { - 15453
hooks - 15454
.iter() - 15455
.map(|hook| { - 15456
if !matches!( - 15457
hook.event.as_str(), - 15458
"session_start" - 15459
| "session-start" - 15460
| "pre_tool_use" - 15461
| "pre-tool-use" - 15462
| "post_tool_use" - 15463
| "post-tool-use" - 15464
| "stop" - 15465
) { - 15466
return Err(format!("unknown hook event '{}'", hook.event)); - 15467
} - 15468
if hook.enabled && hook.command.trim().is_empty() { - 15469
return Err("enabled hooks need a command".into()); - 15470
} - 15471
let timeout_ms = hook.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS); - 15472
if timeout_ms == 0 { - 15473
return Err("hook timeout must be greater than zero".into()); - 15474
} - 15475
let failure_mode = hook.failure_mode.as_deref().unwrap_or("open").trim(); - 15476
if !matches!(failure_mode, "open" | "closed") { - 15477
return Err(format!("unknown hook failure mode '{failure_mode}'")); - 15478
} - 15479
Ok(vak_config::HookConfig { - 15480
event: hook.event.clone(), - 15481
matcher: hook - 15482
.matcher - 15483
.clone() - 15484
.filter(|matcher| !matcher.trim().is_empty()), - 15485
command: hook.command.trim().to_string(), - 15486
timeout_ms: Some(timeout_ms), - 15487
enabled: hook.enabled, - 15488
failure_mode: Some(failure_mode.to_string()), - 15489
}) - 15490
}) - 15491
.collect() - 15492
} - 15493
- 15494
async fn put_hooks( - 15495
State(state): State<AppState>, - 15496
Json(body): Json<HooksPutBody>, - 15497
) -> axum::response::Response { - 15498
use axum::response::IntoResponse; - 15499
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15500
let hooks = match validated_hook_configs(&body.hooks) { - 15501
Ok(hooks) => hooks, - 15502
Err(message) => { - 15503
return ( - 15504
StatusCode::BAD_REQUEST, - 15505
Json(serde_json::json!({ "error": message })), - 15506
) - 15507
.into_response(); - 15508
} - 15509
}; - 15510
// A disabled hook is kept in config, not dropped — round-tripping the - 15511
// toggle used to delete the definition outright (there was nowhere in - 15512
// `[[hooks]]` to record "off"), which is not what a checkbox should do. - 15513
match vak_config::persist_hooks(&vak_config::project_path(core.cwd()), &hooks) { - 15514
Ok(()) => {} - 15515
Err(vak_config::ConfigError::Parse { .. }) => { - 15516
return ( - 15517
StatusCode::BAD_REQUEST, - 15518
Json(serde_json::json!({ "error": "workspace config is invalid" })), - 15519
) - 15520
.into_response(); - 15521
} - 15522
Err(error) => { - 15523
return ( - 15524
StatusCode::INTERNAL_SERVER_ERROR, - 15525
Json(serde_json::json!({ "error": format!("write config: {error}") })), - 15526
) - 15527
.into_response(); - 15528
} - 15529
} - 15530
// Disabled hooks are still handed to Core — `build_hooks_from` is what - 15531
// skips them when it builds the live `HookDef` list — so the effective - 15532
// set stays correct without this endpoint duplicating that filter. - 15533
core.apply_persisted_hooks(hooks); - 15534
let enabled_count = body.hooks.iter().filter(|h| h.enabled).count(); - 15535
vak_core::security_events::record( - 15536
&core.sessions_home(), - 15537
vak_core::security_events::EventKind::ConfigChange, - 15538
"hooks_updated", - 15539
&format!("enabled={enabled_count} total={}", body.hooks.len()), - 15540
None, - 15541
); - 15542
state.hub.emit_config_changed( - 15543
"hooks_updated", - 15544
&format!("enabled={enabled_count} total={}", body.hooks.len()), - 15545
); - 15546
( - 15547
StatusCode::OK, - 15548
Json(serde_json::json!({ "saved": true, "count": enabled_count })), - 15549
) - 15550
.into_response() - 15551
} - 15552
- 15553
#[derive(serde::Deserialize, Clone)] - 15554
struct McpServerInput { - 15555
command: String, - 15556
#[serde(default)] - 15557
args: Vec<String>, - 15558
#[serde(default)] - 15559
env: std::collections::BTreeMap<String, String>, - 15560
#[serde(default)] - 15561
network: bool, - 15562
} - 15563
- 15564
#[derive(serde::Deserialize)] - 15565
struct McpPutBody { - 15566
servers: std::collections::BTreeMap<String, McpServerInput>, - 15567
#[serde(default)] - 15568
agent: Option<String>, - 15569
} - 15570
- 15571
fn valid_server_name(name: &str) -> bool { - 15572
!name.is_empty() - 15573
&& name.len() <= 64 - 15574
&& name - 15575
.chars() - 15576
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) - 15577
} - 15578
- 15579
fn persist_mcp_to_project_config( - 15580
cwd: &std::path::Path, - 15581
servers: &std::collections::BTreeMap<String, McpServerInput>, - 15582
) -> Result<std::path::PathBuf, String> { - 15583
let path = vak_config::project_path(cwd); - 15584
let config = mcp_config_from_input(servers); - 15585
vak_config::persist_mcp_servers(&path, &config.servers).map_err(|error| error.to_string())?; - 15586
Ok(path) - 15587
} - 15588
- 15589
fn mcp_config_from_input( - 15590
servers: &std::collections::BTreeMap<String, McpServerInput>, - 15591
) -> vak_config::McpConfig { - 15592
vak_config::McpConfig { - 15593
servers: servers - 15594
.iter() - 15595
.map(|(name, server)| { - 15596
( - 15597
name.clone(), - 15598
vak_config::McpServerConfig { - 15599
command: server.command.trim().to_string(), - 15600
args: server.args.clone(), - 15601
env: server.env.clone(), - 15602
network: server.network, - 15603
// Undeclared: the per-turn slice never narrows a - 15604
// capability that has not classified itself, so a - 15605
// server configured through the API stays reachable - 15606
// without the operator having to know about domains. - 15607
serves: Vec::new(), - 15608
}, - 15609
) - 15610
}) - 15611
.collect(), - 15612
} - 15613
} - 15614
- 15615
fn read_mcp_config(path: &std::path::Path) -> Result<vak_config::McpConfig, String> { - 15616
if !path.is_file() { - 15617
return Ok(vak_config::McpConfig::default()); - 15618
} - 15619
let raw = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - 15620
toml::from_str::<vak_config::FileConfig>(&raw) - 15621
.map(|config| config.mcp) - 15622
.map_err(|error| error.to_string()) - 15623
} - 15624
- 15625
/// Which layer a setting belongs to. - 15626
/// - 15627
/// One word for one concept: the config section is `workspace_roots`, the - 15628
/// path helper is `default_workspace`, the API is `/workspaces` — and this - 15629
/// said "project". Two names for the same thing is how a UI ends up - 15630
/// labelling one panel "This project" and its own store `workspace`. - 15631
#[derive(Clone, Copy, serde::Deserialize)] - 15632
#[serde(rename_all = "lowercase")] - 15633
enum ConfigScope { - 15634
User, - 15635
Workspace, - 15636
} - 15637
- 15638
impl ConfigScope { - 15639
fn is_workspace(self) -> bool { - 15640
matches!(self, Self::Workspace) - 15641
} - 15642
- 15643
fn label(self) -> &'static str { - 15644
match self { - 15645
Self::User => "user", - 15646
Self::Workspace => "workspace", - 15647
} - 15648
} - 15649
- 15650
/// Root whose `.vak/prompts` directory this scope edits. - 15651
fn prompt_root(self, core: &vak_core::Core) -> std::path::PathBuf { - 15652
match self { - 15653
Self::User => vak_config::paths::default_workspace(), - 15654
Self::Workspace => core.cwd().clone(), - 15655
} - 15656
} - 15657
- 15658
fn config_path(self, core: &vak_core::Core) -> Result<std::path::PathBuf, String> { - 15659
match self { - 15660
Self::User => vak_config::global_path().ok_or_else(|| "user home unavailable".into()), - 15661
Self::Workspace => Ok(vak_config::project_path(core.cwd())), - 15662
} - 15663
} - 15664
} - 15665
- 15666
#[derive(serde::Deserialize)] - 15667
struct ScopeQuery { - 15668
scope: ConfigScope, - 15669
/// Which user-facing Agent's isolated workspace this config layer is - 15670
/// rooted under. Absent means the built-in "vak" Agent, resolved - 15671
/// through `resolve_scoped_core` exactly like the memory/proposal - 15672
/// endpoints (see commit 15c9c256). - 15673
#[serde(default)] - 15674
agent: Option<String>, - 15675
} - 15676
- 15677
/// A scope query where omitting the parameter is legal and means "workspace". - 15678
/// Kept separate from [`ScopeQuery`] so the endpoints that genuinely - 15679
/// require an explicit scope keep rejecting a request without one. - 15680
#[derive(serde::Deserialize)] - 15681
struct OptionalScopeQuery { - 15682
#[serde(default)] - 15683
scope: Option<ConfigScope>, - 15684
#[serde(default)] - 15685
agent: Option<String>, - 15686
} - 15687
- 15688
#[derive(Clone, Copy)] - 15689
struct IntegrationCatalogEntry { - 15690
id: &'static str, - 15691
label: &'static str, - 15692
description: &'static str, - 15693
command: &'static str, - 15694
args: &'static [&'static str], - 15695
env_var: Option<&'static str>, - 15696
key_required: bool, - 15697
documentation_url: &'static str, - 15698
} - 15699
- 15700
/// The curated integrations, **alphabetically**. - 15701
/// - 15702
/// Order is not cosmetic here. Whatever sits first reads as the default, - 15703
/// and this list led with Tavily — which is how one connector came to look - 15704
/// like the real one and the others like extras (doc 46 D5). They are - 15705
/// peers: same shape, same status projection, same scoped read/write path. - 15706
const INTEGRATION_CATALOG: &[IntegrationCatalogEntry] = &[ - 15707
IntegrationCatalogEntry { - 15708
id: "context7", - 15709
label: "Context7", - 15710
description: "Current library documentation and version-specific code examples.", - 15711
command: "npx", - 15712
args: &["-y", "@upstash/context7-mcp@latest"], - 15713
env_var: Some("CONTEXT7_API_KEY"), - 15714
key_required: false, - 15715
documentation_url: "https://github.com/upstash/context7", - 15716
}, - 15717
IntegrationCatalogEntry { - 15718
id: "exa", - 15719
label: "Exa", - 15720
description: "Web, code, company, and research search with page retrieval.", - 15721
command: "npx", - 15722
args: &["-y", "exa-mcp-server"], - 15723
env_var: Some("EXA_API_KEY"), - 15724
key_required: true, - 15725
documentation_url: "https://github.com/exa-labs/exa-mcp-server", - 15726
}, - 15727
IntegrationCatalogEntry { - 15728
id: "firecrawl", - 15729
label: "Firecrawl", - 15730
description: "Search, scrape, crawl, extract, and operate cloud browser sessions.", - 15731
command: "npx", - 15732
args: &["-y", "firecrawl-mcp"], - 15733
env_var: Some("FIRECRAWL_API_KEY"), - 15734
key_required: true, - 15735
documentation_url: "https://github.com/firecrawl/firecrawl-mcp-server", - 15736
}, - 15737
IntegrationCatalogEntry { - 15738
id: "tavily", - 15739
label: "Tavily", - 15740
description: "Real-time web search, extraction, site maps, and crawling.", - 15741
command: "npx", - 15742
args: &["-y", "tavily-mcp@latest"], - 15743
env_var: Some("TAVILY_API_KEY"), - 15744
key_required: true, - 15745
documentation_url: "https://github.com/tavily-ai/tavily-mcp", - 15746
}, - 15747
]; - 15748
- 15749
fn catalog_entry(id: &str) -> Option<IntegrationCatalogEntry> { - 15750
INTEGRATION_CATALOG - 15751
.iter() - 15752
.copied() - 15753
.find(|entry| entry.id == id) - 15754
} - 15755
- 15756
fn catalog_server(entry: IntegrationCatalogEntry) -> vak_config::McpServerConfig { - 15757
vak_config::McpServerConfig { - 15758
command: entry.command.into(), - 15759
args: entry.args.iter().map(|arg| (*arg).to_string()).collect(), - 15760
env: entry - 15761
.env_var - 15762
.map(|name| { - 15763
[(name.to_string(), format!("${{{name}}}"))] - 15764
.into_iter() - 15765
.collect() - 15766
}) - 15767
.unwrap_or_default(), - 15768
network: true, - 15769
// Catalog entries stay undeclared for the same reason: a server the - 15770
// operator just installed must be reachable immediately, and - 15771
// declaring domains only ever narrows. - 15772
serves: Vec::new(), - 15773
} - 15774
} - 15775
- 15776
fn integration_status( - 15777
core: &vak_core::Core, - 15778
scope: ConfigScope, - 15779
entry: IntegrationCatalogEntry, - 15780
) -> Result<serde_json::Value, String> { - 15781
let selected = read_mcp_config(&scope.config_path(core)?)?; - 15782
let user = match vak_config::global_path() { - 15783
Some(path) => read_mcp_config(&path)?, - 15784
None => vak_config::McpConfig::default(), - 15785
}; - 15786
let configured_here = selected.servers.contains_key(entry.id); - 15787
let inherited = scope.is_workspace() && !configured_here && user.servers.contains_key(entry.id); - 15788
let effective = core.effective_mcp().servers.contains_key(entry.id); - 15789
let key_here = entry - 15790
.env_var - 15791
.is_some_and(|name| core.mcp_secret_at_scope(name, scope.is_workspace())); - 15792
let key_inherited = scope.is_workspace() - 15793
&& !key_here - 15794
&& entry - 15795
.env_var - 15796
.is_some_and(|name| core.mcp_secret(name).is_some()); - 15797
Ok(serde_json::json!({ - 15798
"id": entry.id, - 15799
"label": entry.label, - 15800
"description": entry.description, - 15801
"command": entry.command, - 15802
"args": entry.args, - 15803
"network": true, - 15804
"env_var": entry.env_var, - 15805
"key_required": entry.key_required, - 15806
"documentation_url": entry.documentation_url, - 15807
"scope": scope.label(), - 15808
"configured_here": configured_here, - 15809
"inherited": inherited, - 15810
"effective": effective, - 15811
"key_here": key_here, - 15812
"key_inherited": key_inherited, - 15813
"key_effective": entry.env_var.is_none_or(|name| core.mcp_secret(name).is_some()), - 15814
})) - 15815
} - 15816
- 15817
async fn get_integration_catalog( - 15818
State(state): State<AppState>, - 15819
Query(query): Query<ScopeQuery>, - 15820
) -> axum::response::Response { - 15821
use axum::response::IntoResponse; - 15822
let core = scoped_core!(&state, None, query.agent.as_deref()); - 15823
match INTEGRATION_CATALOG - 15824
.iter() - 15825
.copied() - 15826
.map(|entry| integration_status(&core, query.scope, entry)) - 15827
.collect::<Result<Vec<_>, _>>() - 15828
{ - 15829
Ok(integrations) => Json(serde_json::json!({ - 15830
"scope": query.scope.label(), - 15831
"integrations": integrations, - 15832
})) - 15833
.into_response(), - 15834
Err(error) => ( - 15835
StatusCode::INTERNAL_SERVER_ERROR, - 15836
Json(serde_json::json!({ "error": error })), - 15837
) - 15838
.into_response(), - 15839
} - 15840
} - 15841
- 15842
async fn get_scoped_integration( - 15843
State(state): State<AppState>, - 15844
Path(id): Path<String>, - 15845
Query(query): Query<ScopeQuery>, - 15846
) -> axum::response::Response { - 15847
use axum::response::IntoResponse; - 15848
let Some(entry) = catalog_entry(&id) else { - 15849
return StatusCode::NOT_FOUND.into_response(); - 15850
}; - 15851
let core = scoped_core!(&state, None, query.agent.as_deref()); - 15852
match integration_status(&core, query.scope, entry) { - 15853
Ok(status) => Json(status).into_response(), - 15854
Err(error) => ( - 15855
StatusCode::INTERNAL_SERVER_ERROR, - 15856
Json(serde_json::json!({ "error": error })), - 15857
) - 15858
.into_response(), - 15859
} - 15860
} - 15861
- 15862
#[derive(serde::Deserialize)] - 15863
struct IntegrationPutBody { - 15864
scope: ConfigScope, - 15865
key: Option<String>, - 15866
#[serde(default)] - 15867
agent: Option<String>, - 15868
} - 15869
- 15870
fn apply_scoped_mcp_change( - 15871
core: &vak_core::Core, - 15872
scope: ConfigScope, - 15873
id: &str, - 15874
server: Option<vak_config::McpServerConfig>, - 15875
) -> Result<(), String> { - 15876
let path = scope.config_path(core)?; - 15877
vak_config::persist_mcp_server(&path, id, server.as_ref()) - 15878
.map_err(|error| error.to_string())?; - 15879
let effective = vak_config::load_with_trust(core.cwd(), core.project_config_trusted()) - 15880
.map_err(|error| error.to_string())?; - 15881
core.apply_persisted_mcp_servers(effective.mcp); - 15882
Ok(()) - 15883
} - 15884
- 15885
async fn put_scoped_integration( - 15886
State(state): State<AppState>, - 15887
Path(id): Path<String>, - 15888
Json(body): Json<IntegrationPutBody>, - 15889
) -> axum::response::Response { - 15890
use axum::response::IntoResponse; - 15891
let Some(entry) = catalog_entry(&id) else { - 15892
return StatusCode::NOT_FOUND.into_response(); - 15893
}; - 15894
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15895
if let Some(key) = body.key.as_deref() - 15896
&& let Err(error) = core.set_mcp_secret_scoped( - 15897
entry.env_var.unwrap_or_default(), - 15898
key, - 15899
body.scope.is_workspace(), - 15900
) - 15901
{ - 15902
return ( - 15903
StatusCode::BAD_REQUEST, - 15904
Json(serde_json::json!({ "error": error.to_string() })), - 15905
) - 15906
.into_response(); - 15907
} - 15908
let key_available = entry - 15909
.env_var - 15910
.is_none_or(|name| core.mcp_secret(name).is_some()); - 15911
if entry.key_required && !key_available { - 15912
return ( - 15913
StatusCode::BAD_REQUEST, - 15914
Json(serde_json::json!({ - 15915
"error": format!("{} requires {} at this scope or an inherited scope", entry.label, entry.env_var.unwrap_or("a key")) - 15916
})), - 15917
) - 15918
.into_response(); - 15919
} - 15920
if let Err(error) = - 15921
apply_scoped_mcp_change(&core, body.scope, entry.id, Some(catalog_server(entry))) - 15922
{ - 15923
return ( - 15924
StatusCode::INTERNAL_SERVER_ERROR, - 15925
Json(serde_json::json!({ "error": error })), - 15926
) - 15927
.into_response(); - 15928
} - 15929
state.hub.emit_config_changed( - 15930
"integration_enabled", - 15931
&format!("scope={} integration={}", body.scope.label(), entry.id), - 15932
); - 15933
match integration_status(&core, body.scope, entry) { - 15934
Ok(status) => Json(status).into_response(), - 15935
Err(error) => ( - 15936
StatusCode::INTERNAL_SERVER_ERROR, - 15937
Json(serde_json::json!({ "error": error })), - 15938
) - 15939
.into_response(), - 15940
} - 15941
} - 15942
- 15943
async fn delete_scoped_integration( - 15944
State(state): State<AppState>, - 15945
Path(id): Path<String>, - 15946
Query(query): Query<ScopeQuery>, - 15947
) -> axum::response::Response { - 15948
use axum::response::IntoResponse; - 15949
let Some(entry) = catalog_entry(&id) else { - 15950
return StatusCode::NOT_FOUND.into_response(); - 15951
}; - 15952
let core = scoped_core!(&state, None, query.agent.as_deref()); - 15953
if let Some(env_var) = entry.env_var - 15954
&& let Err(error) = core.remove_mcp_secret_scoped(env_var, query.scope.is_workspace()) - 15955
{ - 15956
return ( - 15957
StatusCode::INTERNAL_SERVER_ERROR, - 15958
Json(serde_json::json!({ "error": error.to_string() })), - 15959
) - 15960
.into_response(); - 15961
} - 15962
if let Err(error) = apply_scoped_mcp_change(&core, query.scope, entry.id, None) { - 15963
return ( - 15964
StatusCode::INTERNAL_SERVER_ERROR, - 15965
Json(serde_json::json!({ "error": error })), - 15966
) - 15967
.into_response(); - 15968
} - 15969
state.hub.emit_config_changed( - 15970
"integration_removed", - 15971
&format!("scope={} integration={}", query.scope.label(), entry.id), - 15972
); - 15973
match integration_status(&core, query.scope, entry) { - 15974
Ok(status) => Json(status).into_response(), - 15975
Err(error) => ( - 15976
StatusCode::INTERNAL_SERVER_ERROR, - 15977
Json(serde_json::json!({ "error": error })), - 15978
) - 15979
.into_response(), - 15980
} - 15981
} - 15982
- 15983
async fn get_global_mcp_servers() -> axum::response::Response { - 15984
use axum::response::IntoResponse; - 15985
let Some(path) = vak_config::global_path() else { - 15986
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 15987
}; - 15988
match read_mcp_config(&path) { - 15989
Ok(mcp) => { - 15990
Json(serde_json::json!({ "scope": "global", "path": path, "servers": mcp.servers })) - 15991
.into_response() - 15992
} - 15993
Err(error) => ( - 15994
StatusCode::INTERNAL_SERVER_ERROR, - 15995
Json(serde_json::json!({ "error": error })), - 15996
) - 15997
.into_response(), - 15998
} - 15999
} - 16000
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.