use std::sync::Arc;
use axum::body::{Body, Bytes, to_bytes};
use axum::extract::OriginalUri;
use axum::http::{self, HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use base64::Engine as _;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::handler::server::{ServerHandler, tool::Extension};
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, GetPromptRequestParams, GetPromptResult,
ListPromptsResult, ListToolsResult, PaginatedRequestParams, Prompt, PromptMessage,
PromptMessageContent, PromptMessageRole, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::schemars::JsonSchema;
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, schemars, tool, tool_router};
use crate::infra::execution::placement::ProjectRuntimePlacementTarget;
use crate::platform::model::{McpSession, mcp_tool_capability};
use crate::platform::services::{PlatformOps, PlatformService};
const INTERNAL_CLUSTER_TOKEN_HEADER: &str = "x-zebflow-cluster-token";
const INTERNAL_MCP_SESSION_HEADER: &str = "x-zebflow-mcp-session";
const MCP_PROXY_BODY_LIMIT: usize = 16 * 1024 * 1024;
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineGetParams {
file_rel_path: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineListParams {
#[schemars(with = "String")]
query: Option<String>,
#[schemars(with = "String")]
glob: Option<String>,
#[schemars(with = "String")]
status: Option<String>,
#[schemars(with = "String")]
trigger_kind: Option<String>,
#[schemars(with = "u32")]
limit: Option<u32>,
#[schemars(with = "String")]
format: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileReadParams {
rel_path: String,
#[schemars(with = "u32")]
offset: Option<u32>,
#[schemars(with = "u32")]
limit: Option<u32>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileListParams {
#[schemars(with = "String")]
glob: Option<String>,
#[schemars(with = "String")]
query: Option<String>,
#[schemars(with = "String")]
kind: Option<String>,
#[schemars(with = "u32")]
limit: Option<u32>,
#[schemars(with = "String")]
format: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct HelpParams {
#[schemars(with = "String")]
topic: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileWriteParams {
rel_path: String,
content: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileSearchParams {
pattern: String,
#[schemars(with = "String")]
glob: Option<String>,
#[schemars(with = "u32")]
context: Option<u32>,
#[schemars(with = "u32")]
head_limit: Option<u32>,
#[schemars(with = "String")]
output_mode: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineSearchParams {
pattern: String,
#[schemars(with = "String")]
glob: Option<String>,
#[schemars(with = "u32")]
context: Option<u32>,
#[schemars(with = "u32")]
head_limit: Option<u32>,
#[schemars(with = "String")]
output_mode: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileEditParams {
rel_path: String,
old_string: String,
new_string: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileOutlineParams {
rel_path: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileDepsParams {
rel_path: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct TemplateBatchEditItem {
rel_path: String,
old_string: String,
new_string: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileBatchEditParams {
edits: Vec<TemplateBatchEditItem>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct FileCreateParams {
kind: String,
name: String,
#[schemars(with = "String")]
parent_rel_path: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct DocsAgentReadParams {
name: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct DocsAgentWriteParams {
name: String,
content: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineRegisterParams {
#[serde(default)]
#[schemars(with = "String")]
file_rel_path: Option<String>,
#[serde(default)]
#[schemars(with = "String")]
name: Option<String>,
#[serde(default)]
#[schemars(with = "String")]
path: Option<String>,
#[schemars(with = "String")]
title: Option<String>,
#[serde(default)]
#[schemars(with = "String")]
description: Option<String>,
body: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineCheckParams {
#[serde(default)]
#[schemars(with = "String")]
body: Option<String>,
#[serde(default)]
#[schemars(with = "String")]
file_rel_path: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineDescribeParams {
file_rel_path: String,
#[serde(default)]
compact: bool,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelinePatchParams {
file_rel_path: String,
node_id: String,
#[schemars(with = "String")]
target: Option<String>,
#[schemars(with = "String")]
flags: Option<String>,
#[schemars(with = "String")]
body: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineActivateParams {
#[serde(default)]
file_rel_path: String,
#[serde(default)]
#[schemars(with = "String")]
glob: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct MigrationPlanParams {
#[serde(default)]
#[schemars(with = "String")]
format: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct MigrationApplyParams {
fingerprint: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineDeactivateParams {
file_rel_path: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineExecuteParams {
file_rel_path: String,
#[serde(default)]
input: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineRunParams {
body: String,
#[serde(default)]
input: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct PipelineGetInvocationsParams {
file_rel_path: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct GitCommandParams {
subcommand: String,
#[schemars(with = "String")]
args: Option<String>,
#[schemars(with = "String")]
message: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct SkillReadParams {
name: String,
#[schemars(with = "String")]
path: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct ConnectionDescribeParams {
slug: String,
#[schemars(with = "String")]
scope: Option<String>,
#[schemars(with = "String")]
schema: Option<String>,
#[schemars(with = "String")]
table: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct HelpSearchParams {
query: String,
}
#[derive(serde::Deserialize, JsonSchema)]
struct ThemeGenerateParams {
#[serde(default)]
seed: Option<String>,
#[serde(default)]
mood: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct RouteFetchParams {
path: String,
#[serde(default)]
method: Option<String>,
#[serde(default)]
body: Option<serde_json::Value>,
#[serde(default)]
form: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default)]
files: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default)]
headers: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default)]
cookie: Option<String>,
#[serde(default)]
#[schemars(with = "bool")]
follow_redirects: Option<bool>,
#[serde(default)]
#[schemars(with = "u32")]
max_body_chars: Option<usize>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct HubSearchParams {
#[serde(default)]
query: Option<String>,
#[serde(default)]
kind: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct HubPackageParams {
package_id: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
target_folder: Option<String>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct InstallUiComponentsParams {
names: Vec<String>,
#[serde(default)]
#[schemars(with = "bool")]
overwrite: Option<bool>,
}
#[derive(serde::Deserialize, JsonSchema)]
struct MoveParams {
from_path: String,
to_path: String,
}
#[derive(Clone)]
pub struct ZebflowMcpHandler {
platform: Arc<PlatformService>,
template_cache: crate::pipeline::engines::basic::TemplateCache,
tool_router: rmcp::handler::server::tool::ToolRouter<Self>,
}
#[tool_router]
impl ZebflowMcpHandler {
pub fn new(
platform: Arc<PlatformService>,
template_cache: crate::pipeline::engines::basic::TemplateCache,
) -> Self {
Self {
platform,
template_cache,
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Call this first. Returns the project (its documents, what exists, databases, \
AGENTS.md and MEMORY.md), how a pipeline is written (kinds are family.noun.verb; each node \
adds one key, its noun), how to find a node (help pipeline/nodes, then pipeline/nodes/<kind>, \
then help_search), and which skill each kind of task opens."
)]
async fn start_here(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.start_here().await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(description = "Returns the running platform version string. \
Use this to verify the deployed binary matches the expected Docker image tag.")]
async fn version(&self) -> Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::text(
crate::version::APP_VERSION.to_string(),
)]))
}
#[tool(description = "Hierarchical docs browser. No topic = full index. \
Paths: 'pipeline' (DSL + web patterns), 'pipeline/dsl', 'pipeline/authoring', 'pipeline/web', \
'pipeline/nodes' (every kind, one line each), 'pipeline/nodes/{kind}' (one node: signature, answer, flags, examples), \
'pipeline/examples' (index), 'pipeline/examples/{slug}' (full recipe), \
'web' (TSX pages), 'web/hooks', 'web/tailwind', 'web/design-system', 'web/libraries', \
'tool' (Tool.time/arr/stat/geo), 'db', \
'platform', 'platform/agent', 'platform/api', 'platform/operations', 'platform/workflow'. \
Call help() before writing pipelines or templates.")]
async fn help(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<HelpParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "help")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.help(params.topic.as_deref().unwrap_or(""));
if result.text.starts_with("Error:") {
Err(McpError::invalid_params(result.text, None))
} else {
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
}
#[tool(
description = "Search Zebflow docs. Returns matching chunks from pipeline docs, \
web template docs, node catalog, and all help files. Use for any concept, node name, DSL flag, \
or syntax question. Example: query='jwt', query='sqlite query', query='web.response.send'."
)]
async fn help_search(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<HelpSearchParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "help_search")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.help_search(¶ms.query);
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "List the skills this project's agent can load — name and one-line trigger each. \
A skill is the procedure for one kind of task (building a page, a pipeline, auth, verification…). \
When a task matches a skill, read it with skill_read before acting. \
Blessed skills ship with the platform; a project's own skills/<name>/SKILL.md shadows one by name."
)]
async fn skill_list(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "skill_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.skill_list();
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Read a skill's SKILL.md (the procedure, gates and checks for one kind of task), \
or with `path` a reference file beside it. Read a skill when its trigger matches \
the task at hand; do not read them all."
)]
async fn skill_read(
&self,
Parameters(params): Parameters<SkillReadParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "skill_read")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.skill_read(¶ms.name, params.path.as_deref());
if result.text.starts_with("Error: ") {
return Err(McpError::invalid_params(result.text, None));
}
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "List pipelines as a lean semantic index. Default compact rows are: \
file_rel_path | trigger summary | status | description. \
Use format='json' for full legacy metadata."
)]
async fn pipeline_list(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineListParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.pipeline_list(crate::platform::services::ops::PipelineListOptions {
query: params.query.as_deref(),
glob: params.glob.as_deref(),
status: params.status.as_deref(),
trigger_kind: params.trigger_kind.as_deref(),
limit: params.limit,
format: params.format.as_deref(),
});
ok_or_err(result)
}
#[tool(
description = "Search pipeline .zf.json files for a pattern. Returns file:line matches. \
Use glob to narrow scope (e.g. \"api/*.zf.json\"). \
Equivalent to Grep across pipelines — find which pipelines use a credential, path, or node kind."
)]
async fn pipeline_search(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineSearchParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_search")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.pipeline_search(
¶ms.pattern,
params.glob.as_deref(),
params.context.unwrap_or(0) as usize,
params.head_limit,
params.output_mode.as_deref(),
))
}
#[tool(description = "Get a specific pipeline by file-relative path")]
async fn pipeline_get(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineGetParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_get")?;
if self.pipeline_locked(&session.owner, &session.project, ¶ms.file_rel_path) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.pipeline_get(¶ms.file_rel_path, None);
ok_or_err(result)
}
#[tool(
description = "Register (create or update) a pipeline at file_rel_path from a DSL body. \
The body is the nodes only — pipe mode '| trigger.webhook --route /x | sekejap.query.run -- \"SQL\" | web.response.send --template pages/x.tsx' \
or graph mode '[a] trigger.webhook … [b] … [a] -> [b]' — with no leading 'register …' line (that is the console form). \
It is saved as a draft; call pipeline_activate to make it live. Re-registering a live pipeline makes it stale until activated. \
Each node is written as its kind (family.noun.verb) and adds one key, its noun. \
help(\"pipeline/dsl\") for the grammar, help(\"pipeline/nodes/<kind>\") for a node's flags and answer."
)]
async fn pipeline_register(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineRegisterParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_register")?;
if let Some(ref frp) = params.file_rel_path {
let frp = self
.platform
.projects
.pipeline_identity(&session.owner, &session.project, frp)
.map_err(|error| McpError::internal_error(error.to_string(), None))?;
if self.pipeline_locked(&session.owner, &session.project, &frp) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops
.pipeline_register(
¶ms.body,
params.file_rel_path.as_deref(),
params.name.as_deref(),
params.path.as_deref(),
params.title.as_deref(),
params.description.as_deref(),
)
.await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Check a pipeline before you save it — the first step of check → register → run → fetch. \
Lists every problem pipeline_register would refuse (an unknown kind or flag, a missing required flag, \
a word outside a closed choice such as `--format jpeg`, a duration or size that does not parse, a cycle) \
and, as warnings, every `input.<key>` or `$nodes.<id>.<key>` no upstream node answers, with the key it \
likely meant (`input.result` after a script → `input.script`). Saves nothing. Give `body` (the DSL you \
would register) or `file_rel_path` (a saved pipeline)."
)]
async fn pipeline_check(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineCheckParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_check")?;
if let Some(frp) = params.file_rel_path.as_deref().filter(|p| !p.trim().is_empty())
&& self.pipeline_locked(&session.owner, &session.project, frp)
{
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.pipeline_check(params.body.as_deref(), params.file_rel_path.as_deref()).await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Inspect a pipeline — returns its nodes, edges, status, and hit stats. \
Node IDs from this output are required for pipeline_patch. \
Set compact=true to show one line per node (id | kind | key flags) \
without body content — useful for pipelines with long SQL or scripts."
)]
async fn pipeline_describe(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineDescribeParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_describe")?;
if self.pipeline_locked(&session.owner, &session.project, ¶ms.file_rel_path) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops
.pipeline_describe(¶ms.file_rel_path, params.compact)
.await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Patch one node in a saved pipeline without rewriting the full graph. \
node_id accepts: opaque ID (e.g. 'n0'), node kind (e.g. 'trigger.webhook', 'postgres.query.run'), \
or kind+index (e.g. 'postgres.query.run[1]') when multiple nodes share the same kind. \
Pipeline status becomes stale after patching — call pipeline_activate to make it live again."
)]
async fn pipeline_patch(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelinePatchParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_patch")?;
if self.pipeline_locked(&session.owner, &session.project, ¶ms.file_rel_path) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops
.pipeline_patch(
¶ms.file_rel_path,
params.target.as_deref().unwrap_or("node"),
¶ms.node_id,
params.flags.as_deref(),
params.body.as_deref(),
)
.await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Activate a pipeline — makes it live so it can serve traffic and be executed. \
Must be called after pipeline_register or after patching. \
A pipeline must be active before pipeline_execute will run it. \
Set glob (e.g. \"modules/manage/**\") to bulk-activate all matching pipelines \
instead of activating one at a time."
)]
async fn pipeline_activate(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineActivateParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_activate")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
if let Some(ref glob) = params.glob {
let result = ops.pipeline_activate_glob(glob).await;
return Ok(CallToolResult::success(vec![Content::text(result.text)]));
}
if self.pipeline_locked(&session.owner, &session.project, ¶ms.file_rel_path) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let result = ops.pipeline_activate(¶ms.file_rel_path).await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Plan moving this project from 0.10 to 0.11 — reads only. Every 0.10 pipeline rewritten for the 0.11 \
kinds, flags and answers and put through the save-time check, and every page a 0.10 pipeline renders \
rewritten for the payload it now receives: per file the changes, a diff, the check, behaviour notes \
and what could not be mapped. Answers a fingerprint for migration_apply. A plan that is ready and holds \
no review item was already applied when the server started; this is for the rest. Only for a \
session the project's owner created."
)]
async fn migration_plan(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<MigrationPlanParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "migration_plan")?;
self.check_owner_grant(&session)?;
let service = crate::platform::services::MigrationService::new(self.platform.clone());
let plan = service
.plan_project(&session.owner, &session.project)
.map_err(|e| McpError::internal_error(e.message, None))?;
let text = if params.format.as_deref() == Some("markdown") {
plan.report
} else {
serde_json::to_string_pretty(&plan).unwrap_or_default()
};
Ok(CallToolResult::success(vec![Content::text(text)]))
}
#[tool(
description = "Apply the 0.11 migration plan whose fingerprint is given: each rewritten pipeline is deactivated, its \
0.10 original copied to archive/0.10/<same path>, the rewrite registered at the same path and activated \
again if it was active; each rewritten page is archived and written. Refused when the plan has unresolved \
items or refused checks, or the project changed since the plan. Running it again changes nothing. Only for \
a session the project's owner created."
)]
async fn migration_apply(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<MigrationApplyParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "migration_apply")?;
self.check_owner_grant(&session)?;
let service = crate::platform::services::MigrationService::new(self.platform.clone());
match service.apply_plan(&session.owner, &session.project, ¶ms.fingerprint).await {
Ok(report) => Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&report).unwrap_or_default(),
)])),
Err(e) => Err(McpError::invalid_params(format!("{}: {}", e.code, e.message), None)),
}
}
#[tool(
description = "Deactivate a pipeline — takes it offline. Traffic stops being served. \
Pipeline source is retained and can be re-activated later."
)]
async fn pipeline_deactivate(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineDeactivateParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_deactivate")?;
if self.pipeline_locked(&session.owner, &session.project, ¶ms.file_rel_path) {
return Err(McpError::invalid_params(
"This pipeline is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.pipeline_deactivate(¶ms.file_rel_path).await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Execute a registered active pipeline by name. Records execution hits. \
Pipeline must be activated first — use pipeline_activate if status is draft or stale. \
Use pipeline_list to see pipeline names and activation status. \
For function pipelines (trigger.function) always pass `input` to test with real data; \
without it the pipeline receives an empty payload {}. \
A pipeline with input.* nodes expects the trigger envelope: \
input={\"body\": {\"prompt\": \"x\"}, \"files\": {\"photo\": \"uploads/cat.png\"}} — \
a `files` value is a store path (project files, private or public) that becomes the FileRef of that object."
)]
async fn pipeline_execute(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineExecuteParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_execute")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let input_str = params.input.as_ref().and_then(|v| match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Null => None,
other => serde_json::to_string(other).ok(),
});
let result = ops
.pipeline_execute(¶ms.file_rel_path, input_str.as_deref())
.await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Run a pipe-chained node body EPHEMERALLY — not saved, not logged, no hit recording. \
Use this to test queries, explore data, or prototype before registering. \
Example body: '| postgres.query.run --credential pg_main -- \"SELECT count(*) FROM users\"' — the rows come back at input.query.rows. \
Auto-prepends trigger.manual if no trigger node specified."
)]
async fn pipeline_run(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineRunParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_run")?;
let initial_input = params.input.and_then(|v| match v {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => Some(v),
serde_json::Value::String(s) if !s.is_empty() => serde_json::from_str(&s).ok(),
_ => None,
});
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.pipeline_run(¶ms.body, initial_input).await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "Get recent execution history for a pipeline. Returns stored invocations \
with timestamp, duration, status (ok/error), trigger source, error message, \
and per-node trace. Use this to inspect past runs, debug failures on scheduled \
pipelines, or verify that a pipeline is executing correctly."
)]
async fn pipeline_get_invocations(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<PipelineGetInvocationsParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "pipeline_get_invocations")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.pipeline_get_invocations(¶ms.file_rel_path))
}
#[tool(description = "List templates in the project workspace. Optional glob filters files.")]
async fn file_list(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileListParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_list(crate::platform::services::ops::FileListOptions {
query: params.query.as_deref(),
glob: params.glob.as_deref(),
kind: params.kind.as_deref(),
limit: params.limit,
format: params.format.as_deref(),
});
ok_or_err(result)
}
#[tool(description = "Get a specific template by relative path. \
Use offset and limit to read a line-numbered slice instead of the full file.")]
async fn file_read(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileReadParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_read")?;
if self.template_locked(&session.owner, &session.project, ¶ms.rel_path) {
return Err(McpError::invalid_params(
"This template is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_read(¶ms.rel_path, params.offset, params.limit);
ok_or_err(result)
}
#[tool(description = "Create a new source file with scaffolding. \
Kind must be one of: page (.tsx), component (.tsx), script (.ts), style (.css), doc (.md), folder. \
The file lands at parent_rel_path/name.<ext> relative to the source root — pass \
parent_rel_path=\"pages\" to get pages/<name>.tsx; without it the file is created at the root. \
Returns the scaffolded content — use file_write to customise it after.")]
async fn file_create(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileCreateParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_create")?;
if let Some(ref parent) = params.parent_rel_path {
if self.template_locked(&session.owner, &session.project, parent) {
return Err(McpError::invalid_params(
"This template folder is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_create(
¶ms.kind,
¶ms.name,
params.parent_rel_path.as_deref(),
);
ok_or_err(result)
}
#[tool(description = "Write (create or overwrite) a template file. \
Use file_create first to scaffold with boilerplate, then file_write to fill in content. \
Path is relative to templates/ (e.g. 'pages/blog-home.tsx', 'components/ui/card.tsx', 'scripts/format-address.ts'). \
Use help(\"web\") for TSX conventions or help(\"web/custom-scripts\") for TypeScript module rules before writing. \
A .tsx / .ts file is checked as it is written: the answer's template_problems lists each parse error, each name used \
but never imported and each local `h` with path:line:column — the file is saved anyway; fix them before route_fetch.")]
async fn file_write(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileWriteParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_write")?;
if self.template_locked(&session.owner, &session.project, ¶ms.rel_path) {
return Err(McpError::invalid_params(
"This template is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_write(¶ms.rel_path, ¶ms.content);
if !result.text.starts_with("Error:") {
if let Ok(abs) = self.platform.projects.resolve_template_abs_path(
&session.owner,
&session.project,
¶ms.rel_path,
) {
crate::pipeline::engines::basic::evict_template_cache_by_path(
&self.template_cache,
&abs.to_string_lossy(),
);
}
}
ok_or_err(result)
}
#[tool(
description = "Search template files for a pattern. Returns file:line matches. \
Use glob to narrow scope (e.g. \"pages/*.tsx\", \"**/*.tsx\"). \
Equivalent to Grep across templates — find which files use an import, component, or value."
)]
async fn file_search(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileSearchParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_search")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.file_search(
¶ms.pattern,
params.glob.as_deref(),
params.context.unwrap_or(0) as usize,
params.head_limit,
params.output_mode.as_deref(),
))
}
#[tool(description = "Surgical string replacement in a template file. \
Equivalent to Edit — no need to read the full file first. \
Fails if old_string is not found or matches more than once (provide more context). \
Answers { rel_path, line }; a .tsx / .ts file is checked as it stands after the edit and \
template_problems lists what will break the page, as file_write does.")]
async fn file_edit(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileEditParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_edit")?;
if self.template_locked(&session.owner, &session.project, ¶ms.rel_path) {
return Err(McpError::invalid_params(
"This template is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_edit(¶ms.rel_path, ¶ms.old_string, ¶ms.new_string);
if !result.text.starts_with("Error:") {
if let Ok(abs) = self.platform.projects.resolve_template_abs_path(
&session.owner,
&session.project,
¶ms.rel_path,
) {
crate::pipeline::engines::basic::evict_template_cache_by_path(
&self.template_cache,
&abs.to_string_lossy(),
);
}
}
ok_or_err(result)
}
#[tool(
description = "Parse a template file and return its code outline: imports, exports, \
functions, classes, types, interfaces, and line numbers. \
Use this before file_read when orienting on a large TSX/TS file."
)]
async fn file_outline(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileOutlineParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_outline")?;
if self.template_locked(&session.owner, &session.project, ¶ms.rel_path) {
return Err(McpError::invalid_params(
"This template is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_outline(¶ms.rel_path);
ok_or_err(result)
}
#[tool(
description = "Show a template dependency graph: imports used by this file and \
other project templates that import it. Use before refactoring shared UI."
)]
async fn file_deps(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileDepsParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_deps")?;
if self.template_locked(&session.owner, &session.project, ¶ms.rel_path) {
return Err(McpError::invalid_params(
"This template is locked by the project owner and cannot be accessed by agents. Ask the owner to unlock it.",
None,
));
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.file_deps(¶ms.rel_path);
ok_or_err(result)
}
#[tool(
description = "Apply multiple exact string edits across template files in one call. \
Each edit is rel_path + old_string + new_string. \
Evicts template cache for edited files when the batch succeeds. \
Answers { results }; every .tsx / .ts file it changed is checked once after its edits and \
template_problems lists what will break the page, as file_write does."
)]
async fn file_batch_edit(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<FileBatchEditParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "file_batch_edit")?;
for edit in ¶ms.edits {
if self.template_locked(&session.owner, &session.project, &edit.rel_path) {
return Err(McpError::invalid_params(
format!("Template '{}' is locked", edit.rel_path),
None,
));
}
}
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let edits: Vec<(String, String, String)> = params
.edits
.iter()
.map(|edit| {
(
edit.rel_path.clone(),
edit.old_string.clone(),
edit.new_string.clone(),
)
})
.collect();
let result = ops.file_batch_edit(&edits);
if result.text.contains("ERROR:") {
return Err(McpError::invalid_params(result.text, None));
}
if !result.text.starts_with("Error:") {
for edit in ¶ms.edits {
if let Ok(abs) = self.platform.projects.resolve_template_abs_path(
&session.owner,
&session.project,
&edit.rel_path,
) {
crate::pipeline::engines::basic::evict_template_cache_by_path(
&self.template_cache,
&abs.to_string_lossy(),
);
}
}
}
ok_or_err(result)
}
#[tool(
description = "List the three agent doc files: AGENTS.md (project instructions for agents), \
SOUL.md (agent personality and behavior config), MEMORY.md (persistent memory across sessions). \
Always read AGENTS.md first when starting work on a project."
)]
async fn docs_agent_list(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "docs_agent_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.docs_agent_list();
ok_or_err(result)
}
#[tool(description = "Read one agent doc: AGENTS.md (project instructions), \
SOUL.md (agent personality), or MEMORY.md (persistent memory). \
Read AGENTS.md at the start of every session to understand the project.")]
async fn docs_agent_read(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<DocsAgentReadParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "docs_agent_read")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.docs_agent_read(¶ms.name);
ok_or_err(result)
}
#[tool(
description = "Write an agent doc. AGENTS.md: project-specific instructions for all agents. \
SOUL.md: agent personality, tone, and behavioral config. \
MEMORY.md: persistent notes the agent writes to remember things across sessions. \
Agents should update MEMORY.md after completing significant work."
)]
async fn docs_agent_write(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<DocsAgentWriteParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "docs_agent_write")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.docs_agent_write(¶ms.name, ¶ms.content);
ok_or_err(result)
}
#[tool(
description = "List all DB connections for this project — returns slug, label, and kind (postgresql, sekejap, sqlite, ...). Every project has `default` (sqlite) and `default-multimodel` (sekejap). Use the slug with connection_describe. (`--credential` flags take a credential id from credential_list, not a connection slug.)"
)]
async fn connection_list(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "connection_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.connection_list();
ok_or_err(result)
}
#[tool(
description = "Describe a DB connection's schema — tables, columns, types, constraints. Use scope='tables' for a quick overview, scope='schemas' to list schemas, or omit scope for the full tree. Always run this before writing SQL queries. Use table='schema.table' (e.g. table='academic.staff') to get column detail for a specific table."
)]
async fn connection_describe(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<ConnectionDescribeParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "connection_describe")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops
.connection_describe(
¶ms.slug,
params.scope.as_deref(),
params.schema.as_deref(),
params.table.as_deref(),
)
.await;
ok_or_err(result)
}
#[tool(
description = "List credentials for this project — returns id, title, and kind only. Values are never exposed. Use the id in pipeline nodes that require authentication."
)]
async fn credential_list(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "credential_list")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.credential_list();
ok_or_err(result)
}
#[tool(description = "Run a git command on the project repository. \
Allowed subcommands: status, log, diff, add, commit. \
Destructive operations (reset, rebase, force, checkout) are blocked. \
Always commit after registering or patching pipelines.")]
async fn git_command(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<GitCommandParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "git_command")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops
.git_command(
¶ms.subcommand,
params.args.as_deref(),
params.message.as_deref(),
)
.await;
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
#[tool(
description = "List all available shadcn-compatible Zeb React UI components \
that can be installed into shared/ui/. Returns name, category, description, \
filename, and whether each component is already installed in this project."
)]
async fn list_ui_catalog(
&self,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "list_ui_catalog")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.list_ui_catalog();
ok_or_err(result)
}
#[tool(
description = "Install shadcn-compatible UI components into shared/ui/. \
Pass names like [\"button\",\"card\",\"dialog\"]. \
Set overwrite=true to replace existing files. \
Returns installed and skipped lists."
)]
async fn install_ui_components(
&self,
Parameters(params): Parameters<InstallUiComponentsParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "install_ui_components")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.install_ui_components(params.names, params.overwrite);
ok_or_err(result)
}
#[tool(
description = "Generate a complete theme from one seed colour and one mood — every colour token for light \
and dark as hex, contrast-checked (text 4.5:1, marks 3:1 against every surface), the `:root` / `.dark` \
blocks to paste into globals.css, the mood's fonts (with the Google Fonts link), sizes, radius, spacing \
and shadow, a contrast table, and notes on anything adjusted. `seed` is #RRGGBB (omit for the mood's \
default); `mood` is calm | warm | serious | energetic | playful | luxurious | technical. Never pick \
colours by hand — call this, paste `css`, record the notes in docs/brand.md (skill: brand-system)."
)]
async fn theme_generate(
&self,
Parameters(params): Parameters<ThemeGenerateParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "theme_generate")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.theme_generate(params.seed, params.mood))
}
#[tool(
description = "Fetch one of this project's own routes the way a browser would — the verification step. \
`path` is the route under the project (`/`, `/book`, `/api/slots?date=2026-09-14`); the request goes to \
the project's own host (`<project>.<owner>.localhost`) through the real ingress, so auth, cookies, \
redirects (`location: /admin` means the project's /admin) and page rendering happen exactly as in a browser. Returns status, content_type, location, set_cookie, length, rwe_component_errors (every \
`<!-- RWE component error -->` in the body — a 200 with one of these is a broken page), for HTML an \
`seo` object (title, description_chars, canonical, h1_count, og completeness and whether og:image is \
absolute, twitter_card, jsonld_types, hreflang, images_without_alt, robots, lang — compare these against \
the growth-rules skill instead of reading HTML) and the body (capped, `max_body_chars`). `method` GET|POST|PUT|DELETE; `form` posts url-encoded fields like a <form>; \
`body` sends JSON (an object) or raw text (a string); `files` uploads files as multipart/form-data (field → a store key, \
or `{name, content_type, base64}`; `form` fields go beside them) — a webhook reads them as `input.webhook.files.<field>`; `cookie` is a Cookie header value — copy it from a \
login's set_cookie (`name=value`) to reach protected routes; `follow_redirects` is off by default so you \
see the 302/303 and its location. Fetch every route you built, and every failure path, before saying done."
)]
async fn route_fetch(
&self,
Parameters(params): Parameters<RouteFetchParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "route_fetch")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(
ops.route_fetch(crate::platform::services::ops::RouteFetchRequest {
path: params.path,
method: params.method,
body: params.body,
form: params.form,
files: params.files,
headers: params.headers,
cookie: params.cookie,
follow_redirects: params.follow_redirects,
max_body_chars: params.max_body_chars,
})
.await,
)
}
#[tool(
description = "List what this project can add from the Hub shelf — optional skills, zeb/* libraries, \
pipeline and template bundles, node bundles. Filter with `query` (a word in the id, title, description \
or tags) and `kind` (skill, rwe_library, pipeline, template_pack, folder_bundle, project_bundle, node_bundle). \
Each row carries `package_id` and `latest_version`, which `hub_review` and `hub_add` take. \
The core zebflow-* skills are not here: every project already has them (`skill_list`)."
)]
async fn hub_search(
&self,
Parameters(params): Parameters<HubSearchParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "hub_search")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.hub_search(params.query, params.kind))
}
#[tool(
description = "Review a Hub package before adding it: the files it would add or overwrite, pipelines it \
registers, nodes it uses, credentials it needs, outbound hosts, public endpoints, schedules, and any \
violation that makes it uninstallable. `version` defaults to the latest live release. Read the review, \
then `hub_add` — never the other way round."
)]
async fn hub_review(
&self,
Parameters(params): Parameters<HubPackageParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "hub_review")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.hub_review(params.package_id, params.version, params.target_folder))
}
#[tool(
description = "Add a Hub package to this project (the Studio's Add button). A skill lands at \
`skills/<name>/` and appears in `skill_list`; a zeb/* library under `shared/`; a pipeline or template \
bundle under `target_folder` (default: the package's own folder). Pipelines arrive as drafts — \
`pipeline_activate` them. `version` defaults to the latest live release. Returns files written and \
pipelines registered; the dependency is recorded in zeb.lock."
)]
async fn hub_add(
&self,
Parameters(params): Parameters<HubPackageParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "hub_add")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
ok_or_err(ops.hub_add(params.package_id, params.version, params.target_folder))
}
#[tool(description = "Rename or reorganize a pipeline or template file. \
Domain detected automatically from path: .zf.json = pipeline, anything else = template. \
For pipelines: deactivate → move → re-activate lifecycle handled automatically. \
Parent folders created automatically. No cross-domain moves (pipeline ↔ template).")]
async fn move_resource(
&self,
Extension(parts): Extension<http::request::Parts>,
Parameters(params): Parameters<MoveParams>,
) -> Result<CallToolResult, McpError> {
let session = self.get_session_from_http_parts(&parts)?;
self.check_tool_capability(&session, "move_resource")?;
let ops = PlatformOps::new(self.platform.clone(), &session.owner, &session.project);
let result = ops.move_resource(¶ms.from_path, ¶ms.to_path).await;
ok_or_err(result)
}
fn pipeline_locked(&self, owner: &str, project: &str, file_rel_path: &str) -> bool {
self.platform
.projects
.read_pipeline_source(owner, project, file_rel_path)
.map(|source| {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&source) else {
return false;
};
value
.get("metadata")
.and_then(|m| m.get("locked"))
.and_then(serde_json::Value::as_bool)
.or_else(|| value.get("locked").and_then(serde_json::Value::as_bool))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn template_locked(&self, owner: &str, project: &str, rel_path: &str) -> bool {
self.platform
.zebflow_cfg
.is_template_locked(owner, project, rel_path)
.unwrap_or(false)
}
fn get_session_from_http_parts(
&self,
parts: &http::request::Parts,
) -> Result<McpSession, McpError> {
parts
.extensions
.get::<McpSession>()
.cloned()
.ok_or_else(|| {
McpError::invalid_params(
"No active MCP session; ensure Authorization header is set with valid session token",
None,
)
})
}
fn session_from_context(&self, context: &RequestContext<RoleServer>) -> Option<McpSession> {
context
.extensions
.get::<http::request::Parts>()
.and_then(|parts| parts.extensions.get::<McpSession>().cloned())
}
fn check_owner_grant(&self, session: &McpSession) -> Result<(), McpError> {
if session.granted_by.trim().is_empty() {
return Err(McpError::invalid_params(
"This session does not record who created it; create the MCP session again as the project owner to use this tool.",
None,
));
}
let subject = crate::platform::model::ProjectAccessSubject::user(&session.granted_by);
self.platform
.authz
.ensure_project_capability(
&subject,
&session.owner,
&session.project,
crate::platform::model::ProjectCapability::ProjectDelete,
)
.map_err(|_| {
McpError::invalid_params(
"This tool is the project owner's: the user who created this MCP session is not the owner of the project.",
None,
)
})
}
fn check_tool_capability(&self, session: &McpSession, tool_name: &str) -> Result<(), McpError> {
let required_capability = mcp_tool_capability(tool_name)
.ok_or_else(|| McpError::invalid_params(format!("Unknown tool '{tool_name}'"), None))?;
if session.capabilities.contains(&required_capability) {
return Ok(());
}
Err(McpError::invalid_params(
format!(
"Tool '{tool_name}' requires capability '{}' which is not allowed in this session",
required_capability.key()
),
None,
))
}
}
#[cfg(test)]
pub(crate) fn agent_texts() -> Vec<(String, String)> {
fn descriptions(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, v) in map {
match (key.as_str(), v) {
("description", serde_json::Value::String(text)) => out.push(text.clone()),
_ => descriptions(v, out),
}
}
}
serde_json::Value::Array(items) => items.iter().for_each(|v| descriptions(v, out)),
_ => {}
}
}
let mut texts = vec![("MCP_INSTRUCTIONS".to_string(), MCP_INSTRUCTIONS.to_string())];
for tool in ZebflowMcpHandler::tool_router().list_all() {
let mut parts: Vec<String> = tool.description.iter().map(|d| d.to_string()).collect();
descriptions(&serde_json::Value::Object((*tool.input_schema).clone()), &mut parts);
texts.push((format!("mcp tool {}", tool.name), parts.join("\n")));
}
texts
}
pub const MCP_INSTRUCTIONS: &str = "\
Zebflow is a unified full-stack runtime: one running instance, many projects. \
This MCP server is scoped to ONE project — its repository, store, databases, credentials and hosts. \
Project knowledge lives in the project's files (docs/, AGENTS.md, MEMORY.md); Zebflow knowledge lives in `help` and the skills. Do not confuse the two.\n\n\
Call `start_here` first: the project, how a pipeline is written, how to find a node, and the one topic or skill each task opens — one at a time, per need.\n\n\
A node is written as its kind, `family.noun.verb`, and adds one key, its noun (`input.webhook.body`, `input.query.rows`); `help topic=\"pipeline/dsl\"` is the grammar.\n\n\
Work only through the tools: `pipeline_check`, then `pipeline_register`, then `pipeline_activate`, `file_write` for pages, `route_fetch` to prove a route (a 200 with a component error is not done), `docs_agent_write name=\"MEMORY.md\"` to record what you learned. \
Never write to the filesystem behind the tools, never commit or push unless the user asks (`git_command`).";
impl ServerHandler for ZebflowMcpHandler {
fn get_info(&self) -> ServerInfo {
let instructions = MCP_INSTRUCTIONS.to_string();
ServerInfo {
instructions: Some(instructions.into()),
capabilities: ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.build(),
..Default::default()
}
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: self.tool_router.list_all(),
meta: None,
next_cursor: None,
})
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, McpError> {
let Some(session) = self.session_from_context(&context) else {
return Ok(ListPromptsResult::default());
};
let Ok(layout) = self.platform.projects.project_layout(&session.owner, &session.project) else {
return Ok(ListPromptsResult::default());
};
let prompts = crate::platform::skills::list(&layout.repo_source_dir())
.into_iter()
.map(|skill| Prompt::new(skill.name, Some(skill.description), None))
.collect();
Ok(ListPromptsResult::with_all_items(prompts))
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
context: RequestContext<RoleServer>,
) -> Result<GetPromptResult, McpError> {
let session = self
.session_from_context(&context)
.ok_or_else(|| McpError::invalid_request("no MCP session", None))?;
let layout = self
.platform
.projects
.project_layout(&session.owner, &session.project)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let Some((_, text)) = crate::platform::skills::read(&layout.repo_source_dir(), &request.name, "") else {
return Err(McpError::invalid_params(format!("no skill '{}'", request.name), None));
};
let description = crate::platform::skills::parse_frontmatter(&text).description;
Ok(GetPromptResult {
description,
messages: vec![PromptMessage {
role: PromptMessageRole::User,
content: PromptMessageContent::Text { text },
}],
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
if !self.tool_router.has_route(&request.name) {
return Err(McpError::invalid_params(format!("Unknown tool '{}'", request.name), None));
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tcc).await
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.tool_router.get(name).cloned()
}
}
fn ok_or_err(
result: crate::platform::services::ops::OpsResult,
) -> Result<CallToolResult, McpError> {
if result.text.starts_with("Error:") {
Err(McpError::internal_error(result.text, None))
} else {
Ok(CallToolResult::success(vec![Content::text(result.text)]))
}
}
pub fn build_mcp_service<S: Clone + Send + Sync + 'static>(
platform: Arc<PlatformService>,
template_cache: crate::pipeline::engines::basic::TemplateCache,
) -> axum::Router<S> {
use axum::middleware;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use tokio_util::sync::CancellationToken;
let session_manager = Arc::new(LocalSessionManager::default());
let cancellation_token = CancellationToken::new();
let config = StreamableHttpServerConfig {
sse_keep_alive: Some(std::time::Duration::from_secs(30)),
sse_retry: Some(std::time::Duration::from_secs(5)),
stateful_mode: false,
json_response: true,
cancellation_token: cancellation_token.clone(),
};
let platform_for_factory = platform.clone();
let cache_for_factory = template_cache.clone();
let service = StreamableHttpService::new(
move || {
let platform = platform_for_factory.clone();
let cache = cache_for_factory.clone();
let handler = ZebflowMcpHandler::new(platform, cache);
Ok(handler)
},
session_manager,
config,
);
let platform_for_middleware = platform.clone();
axum::Router::new()
.route_service("/", service)
.layer(middleware::from_fn(
move |mut req: axum::extract::Request, next: middleware::Next| {
let platform = platform_for_middleware.clone();
async move {
let Some((owner, project)) = mcp_project_scope_from_request(&req) else {
return StatusCode::BAD_REQUEST.into_response();
};
let session = match mcp_session_from_request(&platform, req.headers()) {
Some(session) => session,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if crate::platform::model::slug_segment(&session.owner)
!= crate::platform::model::slug_segment(&owner)
|| crate::platform::model::slug_segment(&session.project)
!= crate::platform::model::slug_segment(&project)
{
return StatusCode::FORBIDDEN.into_response();
}
match mcp_remote_project_worker_id(&platform, &owner, &project) {
Ok(Some(worker_id)) => {
return match forward_mcp_request_to_worker(
&platform, req, &worker_id, &session,
)
.await
{
Ok(response) => response,
Err(response) => response,
};
}
Ok(None) => {}
Err(response) => return response,
}
req.extensions_mut().insert(session);
next.run(req).await
}
},
))
}
fn mcp_session_from_request(platform: &PlatformService, headers: &HeaderMap) -> Option<McpSession> {
if let Some(token) = headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(parse_bearer_token)
&& let Some(session) = platform.mcp_sessions.lookup(token)
{
return Some(session);
}
if !is_controller_call(platform, headers) {
return None;
}
let encoded = headers
.get(INTERNAL_MCP_SESSION_HEADER)
.and_then(|h| h.to_str().ok())?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.ok()?;
let session: McpSession = serde_json::from_slice(&bytes).ok()?;
if session.enabled { Some(session) } else { None }
}
fn is_controller_call(platform: &PlatformService, headers: &HeaderMap) -> bool {
let Some(presented) = headers
.get(INTERNAL_CLUSTER_TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
else {
return false;
};
platform
.cluster_join_tokens
.controller_call_authenticates(presented)
}
fn mcp_remote_project_worker_id(
platform: &PlatformService,
owner: &str,
project: &str,
) -> Result<Option<String>, Response> {
let placement = platform
.cluster_placement
.get(owner, project)
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed reading project placement: {}", err.message),
)
.into_response()
})?;
let worker_id = match placement {
Some(record) if record.target == ProjectRuntimePlacementTarget::Worker => record.worker_id,
_ => None,
};
let local_id = platform.cluster_bootstrap.node_id();
if worker_id.as_deref() == Some(local_id.as_str()) {
return Ok(None);
}
Ok(worker_id)
}
async fn forward_mcp_request_to_worker(
platform: &PlatformService,
req: axum::extract::Request,
worker_id: &str,
session: &McpSession,
) -> Result<Response, Response> {
let worker = platform
.cluster_registry
.get_worker(worker_id)
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed reading worker registry: {}", err.message),
)
.into_response()
})?
.ok_or_else(|| {
(
StatusCode::BAD_GATEWAY,
format!("office '{worker_id}' not found"),
)
.into_response()
})?;
let office_id = if worker.office_id.trim().is_empty() {
worker.node_id.as_str()
} else {
worker.office_id.as_str()
};
let token = platform
.cluster_join_tokens
.controller_call_header_for(office_id)
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("{}: {}", err.code, err.message),
)
.into_response()
})?;
let (parts, body) = req.into_parts();
let body = to_bytes(body, MCP_PROXY_BODY_LIMIT).await.map_err(|err| {
(
StatusCode::BAD_REQUEST,
format!("failed reading MCP request body: {err}"),
)
.into_response()
})?;
let original_uri = parts.extensions.get::<OriginalUri>();
let path_and_query = original_uri
.and_then(|uri| uri.path_and_query().map(|pq| pq.as_str().to_string()))
.or_else(|| parts.uri.path_and_query().map(|pq| pq.as_str().to_string()))
.unwrap_or_else(|| parts.uri.path().to_string());
let url = format!(
"{}{}",
worker.base_url.trim_end_matches('/'),
path_and_query
);
let method = reqwest::Method::from_bytes(parts.method.as_str().as_bytes()).map_err(|err| {
(
StatusCode::BAD_REQUEST,
format!("unsupported MCP method '{}': {err}", parts.method),
)
.into_response()
})?;
let mut request = reqwest::Client::new()
.request(method, url)
.header(INTERNAL_CLUSTER_TOKEN_HEADER, token);
for (name, value) in parts.headers.iter() {
let header_name = name.as_str();
if header_name.eq_ignore_ascii_case("host")
|| header_name.eq_ignore_ascii_case("content-length")
|| header_name.eq_ignore_ascii_case("cookie")
|| header_name.eq_ignore_ascii_case(INTERNAL_CLUSTER_TOKEN_HEADER)
|| header_name.eq_ignore_ascii_case(INTERNAL_MCP_SESSION_HEADER)
{
continue;
}
request = request.header(name, value);
}
let session_bytes = serde_json::to_vec(session).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed serializing MCP session: {err}"),
)
.into_response()
})?;
let session_header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(session_bytes);
let response = request
.header(INTERNAL_MCP_SESSION_HEADER, session_header)
.body(body)
.send()
.await
.map_err(|err| {
(
StatusCode::BAD_GATEWAY,
format!("MCP worker proxy failed: {err}"),
)
.into_response()
})?;
Ok(reqwest_response_to_axum(response).await)
}
async fn reqwest_response_to_axum(response: reqwest::Response) -> Response {
let status = response.status();
let headers = response.headers().clone();
let body = response.bytes().await.unwrap_or_else(|_| Bytes::new());
let mut builder = Response::builder().status(status);
for (name, value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case("connection")
|| name.as_str().eq_ignore_ascii_case("transfer-encoding")
{
continue;
}
builder = builder.header(name, value);
}
builder
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
}
fn parse_bearer_token(header_value: &str) -> Option<&str> {
let token = header_value.trim().strip_prefix("Bearer ")?.trim();
if token.is_empty() { None } else { Some(token) }
}
fn mcp_project_scope_from_request(req: &axum::extract::Request) -> Option<(String, String)> {
if let Some(original_uri) = req.extensions().get::<OriginalUri>()
&& let Some(scope) = mcp_project_scope_from_path(original_uri.path())
{
return Some(scope);
}
mcp_project_scope_from_path(req.uri().path())
}
fn mcp_project_scope_from_path(path: &str) -> Option<(String, String)> {
let segments = path
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if segments.len() < 5 {
return None;
}
for index in 0..=(segments.len() - 5) {
if segments[index] == "api"
&& segments[index + 1] == "projects"
&& segments[index + 4] == "mcp"
{
return Some((
segments[index + 2].to_string(),
segments[index + 3].to_string(),
));
}
}
None
}
#[cfg(test)]
mod tests {
use super::{mcp_project_scope_from_path, parse_bearer_token};
#[test]
fn parse_bearer_token_rejects_missing_or_empty_tokens() {
assert_eq!(parse_bearer_token("Bearer abc123"), Some("abc123"));
assert_eq!(parse_bearer_token("Bearer abc123 "), Some("abc123"));
assert_eq!(parse_bearer_token("Bearer "), None);
assert_eq!(parse_bearer_token("Basic abc123"), None);
assert_eq!(parse_bearer_token(""), None);
}
#[test]
fn mcp_project_scope_is_extracted_from_project_url() {
assert_eq!(
mcp_project_scope_from_path("/api/projects/alice/my-app/mcp"),
Some(("alice".to_string(), "my-app".to_string()))
);
assert_eq!(
mcp_project_scope_from_path("/api/projects/alice/my-app/mcp/"),
Some(("alice".to_string(), "my-app".to_string()))
);
assert_eq!(mcp_project_scope_from_path("/api/projects/alice"), None);
assert_eq!(mcp_project_scope_from_path("/mcp"), None);
}
}