Skip to content

feat(mcp): add update_pipeline tool and extend batch mutation tests#395

Merged
streamer45 merged 11 commits into
mainfrom
devin/1777635809-mcp-update-pipeline
May 3, 2026
Merged

feat(mcp): add update_pipeline tool and extend batch mutation tests#395
streamer45 merged 11 commits into
mainfrom
devin/1777635809-mcp-update-pipeline

Conversation

@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor

@staging-devin-ai-integration staging-devin-ai-integration Bot commented May 1, 2026

Summary

Closes #360 — all six proposed mutation tools are now covered:

Proposed tool Covered by
add_node apply_batch (AddNode op)
remove_node apply_batch (RemoveNode op)
connect_nodes apply_batch (Connect op)
disconnect_nodes apply_batch (Disconnect op)
update_node_params tune_node (UpdateParams message)
update_pipeline New — this PR

update_pipeline tool

Takes (session_id, yaml), diffs desired YAML against the running pipeline, and applies the minimal set of batch operations:

  1. Structural diff (disconnect → remove → add → connect):
    • Node additions/removals
    • Node kind changes (replace = remove + re-add, with connection preservation)
    • Connection additions/removals/mode changes (Reliable ↔ BestEffort)
  2. Param diff on surviving nodes:
    • Auto-applied via tune_session_node_replace (full replacement, not deep merge) when caller has tune_nodes permission
    • Otherwise returned in params_deferred for manual follow-up
    • Partial failures logged at warn! with context and returned in params_errors

Key changes

  • apps/skit/src/mcp/mod.rs: update_pipeline tool + diff_pipeline helper with DiffResult
    • Param diff normalizes None / Some({}) for idempotent repeated calls
    • apply_batch_operations errors use invalid_params; tune_session_node errors use internal_error
  • apps/skit/src/server/mod.rs: tune_session_node_replace() — full replacement semantics (no deep merge), gated behind cfg(feature = "mcp")
  • crates/core/src/control.rs: Derive Hash on ConnectionMode (for ConnKey)
  • apps/skit/tests/mcp_integration_test.rs: 14 new integration tests (53 total MCP tests)
  • agent_docs/mcp.md & .agents/skills/mcp-usage/SKILL.md: Updated tool docs + caveats

Review & Testing Checklist for Human

  • diff_pipeline correctness: Verify the 5-step algorithm (disconnect → remove → add → connect → detect param changes) handles edge cases correctly. Key area: apps/skit/src/mcp/mod.rs:1076-1213
  • tune_session_node_replace semantics: Verify the replace flag in tune_session_node_inner correctly skips deep merge. Key area: apps/skit/src/server/mod.rs:4279-4289
  • Permission boundary: update_pipeline requires modify_sessions for structural ops and checks tune_nodes before auto-applying params
  • Param idempotency: None and Some({}) are normalized as equivalent to prevent spurious UpdateParams on repeated calls

Suggested test plan:

  1. Start a server with just skit
  2. Create a session via MCP, then use update_pipeline to:
    • Add/remove nodes
    • Change a node's kind (verify connections preserved)
    • Change connection mode (Reliable → BestEffort)
    • Change params from {a:1, b:2} to {a:9} — verify b is removed
    • Call update_pipeline again with same YAML — verify no-op
  3. Test with a role that has modify_sessions but NOT tune_nodes — verify params_deferred appears

Notes

  • ConnectionMode previously lacked Hash — minor public API addition, no breaking change.
  • TOCTOU gap is intentional (documented in tool description as retry expectation).
  • NodeParamsChanged event broadcasts full params; UIs using incremental deep-merge may see stale keys until full refresh (pre-existing limitation, not introduced by this PR).

Link to Devin session: https://staging.itsdev.in/sessions/16776ed914eb42d28dbd471912ccc998
Requested by: @streamer45


Devin Review

Status Commit
🟢 Reviewed 582566d
Open in Devin Review (Staging)

Add a new `update_pipeline` MCP tool that takes desired pipeline YAML
and a session ID, diffs against the current pipeline state, and applies
the minimal set of batch operations (addnode, removenode, connect,
disconnect) to reconcile the running session. This provides a
declarative "desired state" interface for agents that reason about
pipeline YAML rather than individual graph mutations.

Also add integration tests for previously uncovered batch mutation
operations: connect, disconnect, removenode, and multi-operation
batches. Add tests for the new update_pipeline tool covering add-node,
remove-node, no-op, invalid YAML, and permission denied cases.

Update MCP documentation and skill files to reflect the new tool
(16 tools total).

Closes #360

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +978 to +1000
let current = { session.pipeline.lock().await.clone() };

// Compute diff → batch operations.
let operations = diff_pipeline(&current, &desired);

if operations.is_empty() {
info!(session_id = %args.session_id, "MCP update_pipeline (no changes)");
let result = serde_json::json!({
"operations_applied": 0,
"operations": [],
});
return json_tool_result(&result);
}

// Apply via the shared batch path (validates + applies).
crate::server::apply_batch_operations(
&session,
operations.clone(),
&perms,
&self.app_state.config.security,
)
.await
.map_err(|e| McpError::invalid_params(e, None))?;
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: TOCTOU gap between pipeline snapshot and batch apply is documented and acceptable

The pipeline is snapshotted at line 978 (session.pipeline.lock().await.clone()), then the lock is released, diff is computed, and batch operations are applied. Concurrent mutations between snapshot and apply could cause errors like 'node already exists'. This is explicitly documented in the tool description and in agent_docs/mcp.md:180-182 with the recommendation to retry. The alternative (holding the lock during diff computation) would block the entire session for the duration, which is worse for a multi-tenant server. The current design trades strict atomicity for better concurrency, which is appropriate for this use case.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +1040 to +1085
// 1. Disconnect removed connections (that aren't on nodes being removed).
for conn in &current.connections {
let key = conn_key(conn);
if !desired_conns.contains(&key)
&& desired_node_ids.contains(conn.from_node.as_str())
&& desired_node_ids.contains(conn.to_node.as_str())
{
ops.push(BatchOperation::Disconnect {
from_node: conn.from_node.clone(),
from_pin: conn.from_pin.clone(),
to_node: conn.to_node.clone(),
to_pin: conn.to_pin.clone(),
});
}
}

// 2. Remove nodes that no longer exist.
for node_id in &current_node_ids {
if !desired_node_ids.contains(node_id) {
ops.push(BatchOperation::RemoveNode { node_id: (*node_id).to_string() });
}
}

// 3. Add new nodes.
for (node_id, node) in &desired.nodes {
if !current_node_ids.contains(node_id.as_str()) {
ops.push(BatchOperation::AddNode {
node_id: node_id.clone(),
kind: node.kind.clone(),
params: node.params.clone(),
});
}
}

// 4. Connect new connections.
for conn in &desired.connections {
if !current_conns.contains(&conn_key(conn)) {
ops.push(BatchOperation::Connect {
from_node: conn.from_node.clone(),
from_pin: conn.from_pin.clone(),
to_node: conn.to_node.clone(),
to_pin: conn.to_pin.clone(),
mode: conn.mode,
});
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Operation ordering (disconnects → removes → adds → connects) is correct for apply_batch_operations

The diff produces operations in the order: disconnects, removes, adds, connects. This is safe because apply_batch_operations at apps/skit/src/server/mod.rs:4115 processes them sequentially. Disconnects first remove stale connections between surviving nodes; removes clean up departing nodes (and their remaining connections via line 4136-4138); adds create new nodes; connects wire up new/surviving nodes. A new connection referencing a newly-added node works because adds precede connects. The optimization of skipping explicit disconnects for connections on removed nodes (line 1043-1045 condition) is also correct since RemoveNode handler auto-cleans connections.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread apps/skit/tests/mcp_integration_test.rs
diff_pipeline now detects nodes whose kind changed between current and
desired state and treats them as replacements (remove + re-add). This
prevents a silent no-op when an agent changes a node's kind while
keeping the same node ID.

Connections touching replaced nodes are explicitly disconnected before
removal and reconnected after re-addition per the desired state.

Add mcp_update_pipeline_kind_change integration test to verify the
behavior.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment thread apps/skit/src/mcp/mod.rs
Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +1051 to +1071
// 1. Disconnect removed connections (that aren't on nodes being fully removed).
// Also disconnect connections touching replaced nodes (they will be re-added).
for conn in &current.connections {
let key = conn_key(conn);
let from_replaced = replaced_node_ids.contains(conn.from_node.as_str());
let to_replaced = replaced_node_ids.contains(conn.to_node.as_str());
let from_survives = desired_node_ids.contains(conn.from_node.as_str());
let to_survives = desired_node_ids.contains(conn.to_node.as_str());

if (!desired_conns.contains(&key) || from_replaced || to_replaced)
&& from_survives
&& to_survives
{
ops.push(BatchOperation::Disconnect {
from_node: conn.from_node.clone(),
from_pin: conn.from_pin.clone(),
to_node: conn.to_node.clone(),
to_pin: conn.to_pin.clone(),
});
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Disconnect operations for replaced nodes are redundant with RemoveNode cleanup

The diff logic at apps/skit/src/mcp/mod.rs:1109-1127 explicitly emits Disconnect operations for connections touching replaced nodes, even though the subsequent RemoveNode operation (line 1131-1136) would tear down those connections anyway. The comment acknowledges this design: "Connections on replaced nodes are explicitly disconnected because the node is re-added as a new instance." This is technically safe since disconnects are ordered before removes, but produces slightly larger batch payloads than necessary. In the test mcp_update_pipeline_kind_change_preserves_connections, this manifests as 4 operations (disconnect + removenode + addnode + connect) where 3 might suffice (removenode + addnode + connect). Not a bug since the engine handles it correctly, but worth noting for future optimization.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Step 4 of diff_pipeline now also emits Connect operations for
desired connections that touch replaced nodes, even if those
connections existed in the current pipeline. Previously, connections
on nodes whose kind changed were disconnected (step 1) but never
reconnected because step 4 only emitted Connect for connections
absent from current_conns.

Add mcp_update_pipeline_kind_change_preserves_connections test to
verify connections survive node kind changes.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +1051 to +1071
// 1. Disconnect removed connections (that aren't on nodes being fully removed).
// Also disconnect connections touching replaced nodes (they will be re-added).
for conn in &current.connections {
let key = conn_key(conn);
let from_replaced = replaced_node_ids.contains(conn.from_node.as_str());
let to_replaced = replaced_node_ids.contains(conn.to_node.as_str());
let from_survives = desired_node_ids.contains(conn.from_node.as_str());
let to_survives = desired_node_ids.contains(conn.to_node.as_str());

if (!desired_conns.contains(&key) || from_replaced || to_replaced)
&& from_survives
&& to_survives
{
ops.push(BatchOperation::Disconnect {
from_node: conn.from_node.clone(),
from_pin: conn.from_pin.clone(),
to_node: conn.to_node.clone(),
to_pin: conn.to_pin.clone(),
});
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Replaced-node disconnect logic correctly handles the from_survives/to_survives check

The disconnect phase (lines 1109-1127) checks from_survives && to_survives using desired_node_ids, which includes replaced nodes (since they exist in both current and desired, just with different kind). This ensures connections on replaced nodes are explicitly disconnected before the node is removed and re-added. Connections involving fully-removed nodes are correctly skipped because RemoveNode in apply_batch_operations (apps/skit/src/server/mod.rs:4134-4138) tears down their connections. I verified this for several scenarios: both-endpoints-replaced, one-removed-one-replaced, and one-removed-one-surviving — all produce correct operation sequences.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

- Detect param-only changes and auto-apply via tune_node when
  caller has tune_nodes permission; return params_changed in response
- Include ConnectionMode in ConnKey so Reliable↔BestEffort flips
  are detected as disconnect+reconnect (derive Hash on ConnectionMode)
- Tighten kind-change-preserves-connections assertion to == 4
- Document TOCTOU retry expectation and partial-failure semantics
  in tool description
- Add clarifying comments for multi-edge/dangling-edge reasoning
- Use IndexMap iteration for deterministic operation ordering
- Assert error message contains 'YAML' in invalid test
- Add tests: mode-only change, params-only change,
  both-endpoints-replaced
- Update docs with params-drift and partial-failure caveats

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Copy,
PartialEq,
Eq,
Hash,
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: ConnKey includes mode to detect Reliable↔BestEffort flips as disconnect+reconnect

The ConnKey type at apps/skit/src/mcp/mod.rs:1098 includes ConnectionMode as the 5th tuple element, which is what triggers the Hash derive addition on ConnectionMode in crates/core/src/control.rs. This design ensures mode changes are detected as connection removals + additions (disconnect old mode, connect new mode). The BatchOperation::Disconnect variant doesn't carry a mode field (apps/skit/src/server/mod.rs:4175-4194), so the disconnect removes the connection regardless of mode, and the subsequent Connect re-establishes it with the desired mode. This works correctly because the YAML compiler never emits parallel edges between the same pin pair (as noted in the inline comment at line 1128-1129).

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — Hash is required for the ConnKey tuple used in HashSet. Minimal and non-breaking addition to a simple 2-variant enum.

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +1099 to +1108
let current_conns: HashSet<ConnKey> = current.connections.iter().map(conn_key).collect();
let desired_conns: HashSet<ConnKey> = desired.connections.iter().map(conn_key).collect();

// 1. Disconnect removed/changed connections.
// We skip connections where either endpoint is being fully removed
// (the engine tears those down with RemoveNode). Connections on
// replaced nodes are explicitly disconnected because the node is
// re-added as a new instance. HashSet<ConnKey> collapses true
// parallel edges (same endpoints + same mode) into one entry;
// this is acceptable because the engine also deduplicates them.
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Duplicate connection handling relies on engine-level deduplication

The diff algorithm collects connections into HashSet<ConnKey> which collapses true parallel edges (same endpoints + same mode) into one entry. However, the iteration at line 1109 (for conn in &current.connections) still visits each connection in the Vec individually, potentially emitting duplicate Disconnect operations for the same pin pair. Since BatchOperation::Disconnect has no mode field, these duplicates target the same logical connection. The comment at lines 1106-1108 acknowledges this: "this is acceptable because the engine also deduplicates them." If the engine does NOT handle duplicate disconnects gracefully, this could be an issue, but based on the test suite passing this appears to be safe.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the HashSet<ConnKey> deduplication and the Vec iteration are consistent here because Disconnect has no mode field, so duplicate disconnects for the same pin pair are idempotent at the engine level. The engine's pipeline.connections.retain(...) handles this gracefully.

When desired YAML omits params but the running node has params set,
treat it as resetting to empty object ({}) instead of silently
dropping the change.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs
Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +1078 to +1081
type ConnKey = (String, String, String, String, ConnectionMode);
fn conn_key(c: &streamkit_api::Connection) -> ConnKey {
(c.from_node.clone(), c.from_pin.clone(), c.to_node.clone(), c.to_pin.clone(), c.mode)
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Disconnect is mode-agnostic but diff uses mode-aware ConnKey — potential issue with parallel edges

The diff_pipeline function uses ConnKey = (String, String, String, String, ConnectionMode) to identify connections (line 1078), treating connections with different modes as distinct. However, BatchOperation::Disconnect at apps/skit/src/server/mod.rs:4181-4186 removes ALL connections matching the four endpoint fields regardless of mode. If two connections existed between the same pin pair with different modes and only one should be removed, the Disconnect would remove both, and the surviving one would not be reconnected (since the diff considers it unchanged). In practice, the engine and YAML format likely don't support parallel edges between the same pins, making this theoretical — but worth noting if the model ever allows it.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the engine/YAML format doesn't support parallel edges between the same pin pair, so this is theoretical. The mode-aware ConnKey is specifically for detecting Reliable↔BestEffort flips on the same logical connection, which works correctly since Disconnect removes the one matching connection and the new Connect re-establishes it with the desired mode.

Comment thread agent_docs/mcp.md
Comment thread apps/skit/src/mcp/mod.rs
…pipeline

tune_session_node uses deep_merge_json which preserves keys not
present in the new params. For update_pipeline's full-replacement
semantics, pre-clear node.params to None before calling
tune_session_node so its take() branch skips the merge.

Also fix clippy: unwrap_or with function call → unwrap_or_else.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +1139 to +1160
// 2. Remove nodes that no longer exist or whose kind changed.
// Iterating current.nodes (IndexMap) gives deterministic ordering.
for node_id in current.nodes.keys() {
if !desired_node_ids.contains(node_id.as_str())
|| replaced_node_ids.contains(node_id.as_str())
{
ops.push(BatchOperation::RemoveNode { node_id: node_id.clone() });
}
}

// 3. Add new nodes and re-add replaced nodes with new kind.
for (node_id, node) in &desired.nodes {
if !current_node_ids.contains(node_id.as_str())
|| replaced_node_ids.contains(node_id.as_str())
{
ops.push(BatchOperation::AddNode {
node_id: node_id.clone(),
kind: node.kind.clone(),
params: node.params.clone(),
});
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: diff_pipeline correctly handles replaced-node ordering through check_batch_node_id_uniqueness

At first glance, generating both RemoveNode and AddNode for the same node ID (when its kind changes) looks like it should fail the check_batch_node_id_uniqueness validation in apps/skit/src/server/mod.rs:4013-4034. However, that function simulates operations in order — it removes the ID from live_ids on RemoveNode and re-inserts on AddNode. Since diff_pipeline emits removes before adds (lines 1139-1160), the simulation succeeds. This was verified by tracing through the test case mcp_update_pipeline_kind_change which expects exactly 2 operations (remove + add) and passes.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +969 to +975
// Parse and compile the desired YAML.
let user_pipeline = streamkit_api::yaml::parse_yaml(&args.yaml)
.map_err(|e| McpError::invalid_params(format!("Invalid pipeline YAML: {e}"), None))?;

let desired = streamkit_api::yaml::compile(user_pipeline).map_err(|e| {
McpError::invalid_params(format!("Pipeline compilation error: {e}"), None)
})?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 update_pipeline skips create_session-level validation checks on desired YAML

The create_dynamic_session function (apps/skit/src/server/mod.rs:3874) validates that the pipeline is non-empty, checks for synthetic/oneshot-only node kinds, and performs per-node permission checks. The update_pipeline tool only calls parse_yaml and compile (lines 970-975), then delegates structural validation to apply_batch_operations (which validates AddNode permissions/security). This means:

  1. An update_pipeline that removes ALL nodes produces an empty pipeline — no guard against this.
  2. Synthetic node kinds would fail at the apply_batch_operations level (via validate_add_node_op), so they're still caught.

The empty-pipeline case may be intentional (callers might want to clear a session), but it's a semantic difference from create_session's behavior.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional — update_pipeline mutates an existing session, and apply_batch already allows removing all nodes. Preventing empty pipelines here would be inconsistent with what apply_batch permits directly. An agent clearing all nodes from a session is a valid (if unusual) use case.

…ssions

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +1177 to +1193
// 5. Detect param-only changes on surviving, non-replaced nodes.
let mut params_changed = Vec::new();
for (node_id, desired_node) in &desired.nodes {
if replaced_node_ids.contains(node_id.as_str())
|| !current_node_ids.contains(node_id.as_str())
{
continue;
}
let current_node = &current.nodes[node_id.as_str()];
if desired_node.params != current_node.params {
let new_params = desired_node
.params
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::default()));
params_changed.push((node_id.clone(), new_params));
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Param diff detects changes when YAML omits params but runtime has set them

If a node in the running pipeline has had its params set via tune_node at runtime (e.g., params: Some({"gain": 0.5})), but the desired YAML doesn't specify params for that node (so compiled params: None), diff_pipeline will detect a params change and attempt to reset the params to {} (line 1190's unwrap_or_else). This is correct for 'declarative desired state' semantics but could be surprising to users who expect update_pipeline to only change structural aspects. The documentation should perhaps clarify that omitting params in the YAML means 'reset to empty', not 'leave unchanged'.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

…comments

- Add tune_session_node_replace() with full replacement semantics,
  eliminating the TOCTOU-prone pre-clear hack in update_pipeline
- Accumulate partial param failures with warn! log + params_errors
  in response instead of early-returning on first error
- Use McpError::internal_error for apply_batch/tune failures
  (not invalid_params — those aren't parameter validation errors)
- Fix incorrect comment claiming engine deduplicates parallel edges
  (YAML compiler doesn't emit them; engine doesn't deduplicate)
- Add test verifying param replacement removes old keys
  ({a:1,b:2} -> {a:9} must drop b)

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +1130 to +1148
for conn in &current.connections {
let key = conn_key(conn);
let from_replaced = replaced_node_ids.contains(conn.from_node.as_str());
let to_replaced = replaced_node_ids.contains(conn.to_node.as_str());
let from_survives = desired_node_ids.contains(conn.from_node.as_str());
let to_survives = desired_node_ids.contains(conn.to_node.as_str());

if (!desired_conns.contains(&key) || from_replaced || to_replaced)
&& from_survives
&& to_survives
{
ops.push(BatchOperation::Disconnect {
from_node: conn.from_node.clone(),
from_pin: conn.from_pin.clone(),
to_node: conn.to_node.clone(),
to_pin: conn.to_pin.clone(),
});
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Disconnect logic correctly skips connections on fully-removed nodes

The from_survives && to_survives guard at line 1138-1139 skips emitting explicit Disconnect operations when either endpoint node is being fully removed (not in desired_node_ids). This relies on the engine's RemoveNode handler tearing down connections involving the removed node. Verified this is correct by checking apply_batch_operations at apps/skit/src/server/mod.rs:4134-4138 which does pipeline.connections.retain(|conn| conn.from_node != node_id && conn.to_node != node_id) during RemoveNode processing. The comment in the diff function accurately describes this contract.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs
///
/// Also detects param-only changes on surviving nodes and returns them
/// separately so the caller can apply them via `tune_node`.
fn diff_pipeline(current: &Pipeline, desired: &Pipeline) -> DiffResult {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Pipeline metadata (name, description, mode) silently ignored by diff

diff_pipeline only compares nodes and connections, ignoring top-level fields like name, description, and mode. This means if the desired YAML changes the pipeline mode (e.g. from dynamic to oneshot) or metadata, those changes are silently dropped. This is consistent with the available BatchOperation variants (which only support structural graph mutations), but could surprise callers who expect full YAML reconciliation. The tool description focuses on 'addnode, removenode, connect, disconnect' which sets the right expectation, but this limitation isn't explicitly called out as a caveat.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +4279 to 4288
node.params = Some(if replace {
durable_params
} else {
match node.params.take() {
Some(existing) => {
crate::websocket_handlers::deep_merge_json(existing, durable_params)
},
None => durable_params,
}
});
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Replace semantics correctly avoid deep-merge for declarative updates

The tune_session_node_inner function's replace branch at apps/skit/src/server/mod.rs:4279-4280 directly assigns durable_params instead of deep-merging. This is critical for update_pipeline's declarative semantics: if the desired YAML removes a param key, the old key must actually disappear from the durable model rather than persist via merge. The test mcp_update_pipeline_params_replace_removes_old_keys at line 2137 explicitly verifies this by checking that key b is removed when updating from {a:1, b:2} to {a:9}.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

- Normalize None and Some({}) as equivalent in param diff so
  repeated update_pipeline calls with the same YAML are true no-ops
- Gate tune_session_node_replace behind cfg(feature = "mcp") to
  avoid dead_code warning in non-MCP builds

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread apps/skit/src/mcp/mod.rs Outdated
Comment on lines +4280 to 4291
node.params = Some(if replace {
durable_params
} else {
match node.params.take() {
Some(existing) => {
crate::websocket_handlers::deep_merge_json(existing, durable_params)
},
None => durable_params,
}
});
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 NodeParamsChanged event broadcasts full params — UI merge semantics may not reflect key removal

When tune_session_node_replace is called (replace=true), the durable pipeline model gets a full replacement of params. However, the NodeParamsChanged event at apps/skit/src/server/mod.rs:4293-4304 broadcasts the incoming params value. If the UI applies deep-merge semantics on receiving this event (as it does for regular tune_node), keys removed from the YAML won't be removed from the UI state until the next full state fetch. This is a pre-existing limitation of the event system (not introduced by this PR), and is mitigated by the fact that the broadcast contains the full desired params. Still, for UIs that track param state incrementally, this could cause stale keys to linger.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is a pre-existing limitation of the NodeParamsChanged event system. The event broadcasts the full desired params so UIs that do a full state refresh will be correct. UIs that apply incremental deep-merge on this event may see stale keys until a full fetch. Not something to address in this PR as it applies to all tune_node usage, not just update_pipeline.

Comment thread apps/skit/src/mcp/mod.rs
Comment on lines +1199 to +1203
let normalise = |p: &Option<serde_json::Value>| match p {
Some(serde_json::Value::Object(m)) if m.is_empty() => None,
other => other.clone(),
};
if normalise(&desired_node.params) != normalise(&current_node.params) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Param normalisation treats None and Some({}) as equivalent for idempotency

The normalise closure at apps/skit/src/mcp/mod.rs:1199-1202 converts Some(Value::Object(empty_map)) to None before comparison. This is important because after a replace operation sets params to Some({}), a subsequent update_pipeline call with the same YAML (where params is None) would otherwise detect a spurious change. This normalisation ensures true idempotency for repeated calls. However, it also means that Some({}) in YAML and omitting params entirely are semantically identical — which is the correct behavior for this declarative tool.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

apply_batch_operations returns validation/permission errors (e.g.
"node already exists", "permission denied"), not engine-level
failures. Keep invalid_params consistent with the apply_batch tool.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@streamer45 streamer45 merged commit 1bea309 into main May 3, 2026
17 checks passed
@streamer45 streamer45 deleted the devin/1777635809-mcp-update-pipeline branch May 3, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): Phase 2 — graph mutation tools

2 participants