Recipe: debugging pipeline steps
Symptom inventory
When render-view returns but a widget shows an error or wrong data, the cause is almost always one of:
- The step didn't run (registry miss / missing required keys).
- The step ran but threw — its error landed in
context.errors. - The step ran but produced the wrong shape — widget reads from
context.stepData[stepId].keysand finds nothing useful.
The framework's context.errors array surfaces (1) and (2) with a human-readable reason. Read it first.
Inspect the raw render-view result
curl -sX POST http://localhost:3010/mcp \
-H 'content-type: application/json' \
-d @- <<'JSON' | jq '.result.structuredContent'
{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "render-view",
"arguments": {
"keys": { "articles:articleId": "1" },
"steps": [{ "id": "article", "step": "articles:resolve-article" }],
"layout": { "rows": [{ "row": [{ "widget": "articles:article-card", "span": 6 }] }] }
}
}
}
JSONYou'll see context.errors and per-step context.stepData. If errors is non-empty, you're past the manifest layer — start there.
Common error reasons + fixes
reason | Cause | Fix |
|---|---|---|
Step "X" not registered | Step ID typo, or the module isn't in MCP_ACTIVE_MODULES. | Check the registry: call get-framework-manifest. |
Missing required keys: foo, bar | Caller didn't pass the keys; or a previous step didn't produce them. | Inspect context.keys after the previous step ran (or pass them in keys). |
<message from step> | Step itself threw. | Add a console.error inside the step or read your server logs. |
Step didn't get a typed callTool
Symptoms: the step throws callTool is not a function or a TypeScript error at the call site. The plugin didn't inject the closure: callTool only exists if the plugin puts it into its own AppPlugin.appConfig (see the articles module's createArticlesCallTool). Check the plugin factory and that the step's app prefix matches definition.name.
Step ran but the widget renders empty
Common reasons:
- The step returned
keys: { item: result }but the widget readscontext.keys.itemPayload. Reconcile the key name. - The step's
_dataTypedoesn't match what a downstream check expects.dataTypeis a label — pick deliberate values ("articles:article", not"any"). - The widget uses
useToolQueryto fetch its own data and that call failed silently. Inspect the iframe's React DevTools or add aconsole.errorinside the widget.
userId not propagating
render-view resolves the caller with resolvePipelineContext(ctx) and threads { userId, authenticated } through executePipeline → bindAppConfig → callTool. If your user-scoped callTool closure sees userId: undefined:
authenticatedis absent too: the MCP request is unauthenticated.oauth?was omitted fromcreateFrameworkApp, or the user hasn't logged in.authenticated: true: the provider maps no id. The caller id isctx.auth.user.id, thenuser.userId, then the verified token'ssub— check what your provider'smapAuthInforeturns asuser. Refuse per-user data for such a caller; never fall back to global scope.- The middleware order is wrong (oauth provider must run before org-gate / role-filter / framework tools).
createFrameworkAppdoes this for you; if you boot the server manually, replicate the order shown inpackages/core/src/tools/create-framework-app.ts.
Add transient logging to the executor
The executor is small enough to read straight through — packages/core/src/engine/pipeline-executor.ts. For deeper diagnostics, drop a console.log inside bindAppConfig to see what appConfig and ctx each step actually sees.