stageflow 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/agent/claudeAdapter.js +31 -1
- package/dist/agent/piAdapter.d.ts +6 -4
- package/dist/agent/piAdapter.js +66 -24
- package/dist/agent/piIsolatedMcp.d.ts +23 -0
- package/dist/agent/piIsolatedMcp.js +223 -0
- package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
- package/dist/agent/piIsolatedMcpProbe.js +292 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/port.js +1 -1
- package/dist/config/loadPipeline.js +1 -0
- package/dist/config/loadStage.d.ts +1 -0
- package/dist/config/loadStage.js +69 -0
- package/dist/config/normalizePipelineStageEntry.js +7 -1
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -0
- package/dist/config/resolveStageMcpServers.js +207 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +51 -0
- package/dist/mcp/readArtifact.d.ts +2 -0
- package/dist/mcp/readArtifact.js +25 -1
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runtime/stageAttemptBootstrap.js +38 -2
- package/dist/runtime/verifiedStageExecution.d.ts +2 -0
- package/dist/runtime/verifiedStageExecution.js +10 -2
- package/dist/server/http.js +23 -4
- package/dist/server/projectMcpRoutes.d.ts +6 -0
- package/dist/server/projectMcpRoutes.js +38 -0
- package/dist/types/pipeline.d.ts +2 -0
- package/dist/types/stage.d.ts +2 -1
- package/dist/ui/assets/index-Yzh42vhf.js +118 -0
- package/dist/ui/index.html +1 -1
- package/package.json +4 -1
- package/skills/stageflow-author/SKILL.md +20 -2
- package/skills/stageflow-author/references/catalog-mapping.md +63 -2
- package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
- package/dist/ui/assets/index-CXGz4nL7.js +0 -118
package/README.md
CHANGED
|
@@ -193,6 +193,8 @@ Host MCP via `sf ui` or `sf mcp` at `http://127.0.0.1:3847/mcp` (URL printed on
|
|
|
193
193
|
|
|
194
194
|
HITL-aware tools include `wait_run`, `answer_gate`, and `list_waiting`. Full tool list: [docs/mcp.md](docs/mcp.md).
|
|
195
195
|
|
|
196
|
+
Stages use project `.mcp.json` plus stage `mcp` names. See [YAML catalog — Stage MCP](docs/yaml-catalog.md#stage-mcp).
|
|
197
|
+
|
|
196
198
|
## Stageflow vs Conductor
|
|
197
199
|
|
|
198
200
|
Both projects address multi-step agent workflows. They differ in orchestration model and runtime.
|
|
@@ -218,6 +220,9 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
|
|
|
218
220
|
| [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
|
|
219
221
|
| [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
|
|
220
222
|
| [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
|
|
223
|
+
| [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
|
|
224
|
+
| [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
|
|
225
|
+
| [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
|
|
221
226
|
| [github-release](examples/github-release/) | Dogfood: draft + publish GitHub Release |
|
|
222
227
|
| [ci-validate](examples/ci-validate/) | Strict validate in CI |
|
|
223
228
|
|
|
@@ -241,7 +246,6 @@ Full docs: **[tejasghutukade.github.io/stageflow](https://tejasghutukade.github.
|
|
|
241
246
|
| [docs/providers.md](docs/providers.md) | Pi providers, `sf providers` |
|
|
242
247
|
| [docs/operator-console.md](docs/operator-console.md) | Console IA and settings |
|
|
243
248
|
| [docs/skills-suite.md](docs/skills-suite.md) | Harness skills — router + jobs for Cursor, Claude Code, Codex, Pi, OpenCode |
|
|
244
|
-
| [docs/compare-conductor.md](docs/compare-conductor.md) | Positioning deep dive |
|
|
245
249
|
|
|
246
250
|
## Develop from source
|
|
247
251
|
|
|
@@ -46,6 +46,7 @@ import { createClaudeActivityMapper } from "./claudeActivity.js";
|
|
|
46
46
|
import { composeStageUserPrompt } from "./piAdapter.js";
|
|
47
47
|
import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
|
|
48
48
|
import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
|
|
49
|
+
import { StageMcpError } from "../config/resolveStageMcpServers.js";
|
|
49
50
|
/** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
|
|
50
51
|
const CLAUDE_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Bash"];
|
|
51
52
|
function resolveClaudeModel(stageModel) {
|
|
@@ -87,6 +88,20 @@ function resultFromCapture(capture) {
|
|
|
87
88
|
}
|
|
88
89
|
return { ok: true, envelope: capture.envelope };
|
|
89
90
|
}
|
|
91
|
+
function failedPassedMcpConnect(resolved, listed) {
|
|
92
|
+
const names = Object.keys(resolved ?? {});
|
|
93
|
+
if (names.length === 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const byName = new Map(listed.map((entry) => [entry.name, entry]));
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
const entry = byName.get(name);
|
|
98
|
+
if (entry?.status === "connected")
|
|
99
|
+
continue;
|
|
100
|
+
const statusOrError = entry?.error ?? entry?.status ?? "missing";
|
|
101
|
+
return new StageMcpError(`MCP server "${name}" failed to connect (status: ${statusOrError})`, "connect_failed");
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
90
105
|
async function* singleUserMessage(text) {
|
|
91
106
|
yield {
|
|
92
107
|
type: "user",
|
|
@@ -131,6 +146,10 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
131
146
|
}
|
|
132
147
|
: {}),
|
|
133
148
|
});
|
|
149
|
+
const passedServers = Object.fromEntries(Object.entries(input.resolvedMcpServers ?? {}).map(([name, config]) => [
|
|
150
|
+
name,
|
|
151
|
+
{ ...config, alwaysLoad: true },
|
|
152
|
+
]));
|
|
134
153
|
const stream = query({
|
|
135
154
|
prompt: singleUserMessage(promptText),
|
|
136
155
|
options: {
|
|
@@ -140,10 +159,11 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
140
159
|
systemPrompt: { type: "custom", prompt: input.stage.system_prompt },
|
|
141
160
|
settingSources: [],
|
|
142
161
|
tools: CLAUDE_BUILTIN_TOOLS,
|
|
143
|
-
mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer },
|
|
162
|
+
mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer, ...passedServers },
|
|
144
163
|
permissionMode: "bypassPermissions",
|
|
145
164
|
allowDangerouslySkipPermissions: true,
|
|
146
165
|
persistSession: true,
|
|
166
|
+
strictMcpConfig: true,
|
|
147
167
|
...(resumeSessionId !== undefined ? { resume: resumeSessionId } : {}),
|
|
148
168
|
},
|
|
149
169
|
});
|
|
@@ -154,6 +174,16 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
154
174
|
if (message.type === "system" && message.subtype === "init") {
|
|
155
175
|
sessionId = message.session_id;
|
|
156
176
|
await writeClaudeSessionMarker(markerPath, { sessionId: message.session_id });
|
|
177
|
+
const connectError = failedPassedMcpConnect(input.resolvedMcpServers, message.mcp_servers);
|
|
178
|
+
if (connectError) {
|
|
179
|
+
try {
|
|
180
|
+
await stream.interrupt();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// best-effort backstop; a natural stop may have already ended the turn
|
|
184
|
+
}
|
|
185
|
+
return { kind: "completed", result: { ok: false, reason: connectError.message } };
|
|
186
|
+
}
|
|
157
187
|
}
|
|
158
188
|
if (message.type === "user" &&
|
|
159
189
|
(emitCapture.envelope !== undefined || askCapture.prompt !== undefined)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentSession, DefaultResourceLoader, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { type AgentSession, type EventBus, type InlineExtension, DefaultResourceLoader, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { StageRoots } from "../runtime/stageRoots.js";
|
|
3
3
|
import { type AskOperatorPrompt, type AskOperatorWaitBridge } from "../tools/askOperator.js";
|
|
4
4
|
import "./cursorProvider.js";
|
|
@@ -72,9 +72,9 @@ export declare function composeFeedbackResumePrompt(input: StageRunInput): strin
|
|
|
72
72
|
* up the consumer project's AGENTS.md, `.agents/skills/`, `.pi/extensions`,
|
|
73
73
|
* and APPEND_SYSTEM.md. Stages must not inherit that context.
|
|
74
74
|
*
|
|
75
|
-
* `additionalExtensionPaths` is the
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* `additionalExtensionPaths` is the Cursor/provider seam. `extensionFactories`
|
|
76
|
+
* is the isolated MCP seam. With `noExtensions: true`, discovered
|
|
77
|
+
* global/project packages stay out; only those allowlists load.
|
|
78
78
|
* `additionalSkillPaths` is the matching allowlist for one named skill.
|
|
79
79
|
*/
|
|
80
80
|
export declare function createSealedResourceLoader(options: {
|
|
@@ -84,6 +84,8 @@ export declare function createSealedResourceLoader(options: {
|
|
|
84
84
|
systemPrompt: string;
|
|
85
85
|
additionalExtensionPaths?: string[];
|
|
86
86
|
additionalSkillPaths?: string[];
|
|
87
|
+
extensionFactories?: InlineExtension[];
|
|
88
|
+
eventBus?: EventBus;
|
|
87
89
|
}): DefaultResourceLoader;
|
|
88
90
|
/** Stable Pi session JSONL path under the run workspace stage dir. */
|
|
89
91
|
export declare function stageSessionFilePath(roots: Pick<StageRoots, "runWorkspaceDir" | "attempt">, stageId: string): string;
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -33,6 +33,8 @@ import { existsSync } from "node:fs";
|
|
|
33
33
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, resolveCliModel, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { StageMcpError } from "../config/resolveStageMcpServers.js";
|
|
37
|
+
import { attachIsolatedMcp, STAGEFLOW_PI_MCP_EXTENSION_NAME, } from "./piIsolatedMcp.js";
|
|
36
38
|
import { isAdvancingEnvelope } from "../envelope/check.js";
|
|
37
39
|
import { formatFeedbackLoopContext } from "../prompt/feedbackLoopContext.js";
|
|
38
40
|
import { formatPriorEnvelope } from "../prompt/priorEnvelope.js";
|
|
@@ -464,9 +466,9 @@ export function composeFeedbackResumePrompt(input) {
|
|
|
464
466
|
* up the consumer project's AGENTS.md, `.agents/skills/`, `.pi/extensions`,
|
|
465
467
|
* and APPEND_SYSTEM.md. Stages must not inherit that context.
|
|
466
468
|
*
|
|
467
|
-
* `additionalExtensionPaths` is the
|
|
468
|
-
*
|
|
469
|
-
*
|
|
469
|
+
* `additionalExtensionPaths` is the Cursor/provider seam. `extensionFactories`
|
|
470
|
+
* is the isolated MCP seam. With `noExtensions: true`, discovered
|
|
471
|
+
* global/project packages stay out; only those allowlists load.
|
|
470
472
|
* `additionalSkillPaths` is the matching allowlist for one named skill.
|
|
471
473
|
*/
|
|
472
474
|
export function createSealedResourceLoader(options) {
|
|
@@ -478,6 +480,10 @@ export function createSealedResourceLoader(options) {
|
|
|
478
480
|
appendSystemPromptOverride: () => [],
|
|
479
481
|
additionalExtensionPaths: options.additionalExtensionPaths,
|
|
480
482
|
additionalSkillPaths: options.additionalSkillPaths,
|
|
483
|
+
...(options.extensionFactories !== undefined
|
|
484
|
+
? { extensionFactories: options.extensionFactories }
|
|
485
|
+
: {}),
|
|
486
|
+
...(options.eventBus !== undefined ? { eventBus: options.eventBus } : {}),
|
|
481
487
|
noContextFiles: true,
|
|
482
488
|
noExtensions: true,
|
|
483
489
|
noSkills: true,
|
|
@@ -485,6 +491,16 @@ export function createSealedResourceLoader(options) {
|
|
|
485
491
|
noThemes: true,
|
|
486
492
|
});
|
|
487
493
|
}
|
|
494
|
+
function collectMcpExtensionToolNames(loader) {
|
|
495
|
+
const inlinePath = `<inline:${STAGEFLOW_PI_MCP_EXTENSION_NAME}>`;
|
|
496
|
+
const names = [];
|
|
497
|
+
for (const ext of loader.getExtensions().extensions) {
|
|
498
|
+
if (ext.path !== inlinePath)
|
|
499
|
+
continue;
|
|
500
|
+
names.push(...ext.tools.keys());
|
|
501
|
+
}
|
|
502
|
+
return names;
|
|
503
|
+
}
|
|
488
504
|
async function shutdownSession(session) {
|
|
489
505
|
if (!session) {
|
|
490
506
|
return;
|
|
@@ -770,6 +786,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
770
786
|
additionalExtensionPaths.push(...prepared.extensionPaths);
|
|
771
787
|
restoreProvider = prepared.restore;
|
|
772
788
|
}
|
|
789
|
+
let attached;
|
|
773
790
|
try {
|
|
774
791
|
const modelRuntime = await ModelRuntime.create(roots.authPath
|
|
775
792
|
? {
|
|
@@ -785,6 +802,12 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
785
802
|
const settingsManager = SettingsManager.inMemory({
|
|
786
803
|
compaction: { enabled: false },
|
|
787
804
|
});
|
|
805
|
+
attached = await attachIsolatedMcp(input.resolvedMcpServers);
|
|
806
|
+
const failAfterAttach = (reason) => {
|
|
807
|
+
attached?.cancel?.();
|
|
808
|
+
restoreProvider?.();
|
|
809
|
+
return { ok: false, reason };
|
|
810
|
+
};
|
|
788
811
|
const loader = createSealedResourceLoader({
|
|
789
812
|
cwd: roots.cwd,
|
|
790
813
|
agentDir: roots.agentDir,
|
|
@@ -794,47 +817,43 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
794
817
|
...(input.skillFilePath !== undefined
|
|
795
818
|
? { additionalSkillPaths: [input.skillFilePath] }
|
|
796
819
|
: {}),
|
|
820
|
+
...(attached.extensionFactories !== undefined
|
|
821
|
+
? { extensionFactories: attached.extensionFactories }
|
|
822
|
+
: {}),
|
|
823
|
+
...(attached.eventBus !== undefined ? { eventBus: attached.eventBus } : {}),
|
|
797
824
|
});
|
|
798
825
|
await loader.reload();
|
|
799
826
|
const extensionErrors = loader.getExtensions().errors;
|
|
800
827
|
if (extensionErrors.length > 0) {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
reason: `Failed to load Pi extension(s): ${extensionErrors
|
|
805
|
-
.map((e) => `${e.path}: ${e.error}`)
|
|
806
|
-
.join("; ")}`,
|
|
807
|
-
};
|
|
828
|
+
return failAfterAttach(`Failed to load Pi extension(s): ${extensionErrors
|
|
829
|
+
.map((e) => `${e.path}: ${e.error}`)
|
|
830
|
+
.join("; ")}`);
|
|
808
831
|
}
|
|
809
832
|
const skillDiagnostics = loader
|
|
810
833
|
.getSkills()
|
|
811
834
|
.diagnostics.filter((d) => d.type === "error" || d.type === "collision");
|
|
812
835
|
if (skillDiagnostics.length > 0) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
reason: `Failed to load skill(s): ${skillDiagnostics
|
|
817
|
-
.map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
|
|
818
|
-
.join("; ")}`,
|
|
819
|
-
};
|
|
836
|
+
return failAfterAttach(`Failed to load skill(s): ${skillDiagnostics
|
|
837
|
+
.map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
|
|
838
|
+
.join("; ")}`);
|
|
820
839
|
}
|
|
821
840
|
if (input.stage.skill !== undefined &&
|
|
822
841
|
!loader.getSkills().skills.some((skill) => skill.name === input.stage.skill)) {
|
|
823
|
-
|
|
824
|
-
return {
|
|
825
|
-
ok: false,
|
|
826
|
-
reason: `Skill "${input.stage.skill}" is not installed`,
|
|
827
|
-
};
|
|
842
|
+
return failAfterAttach(`Skill "${input.stage.skill}" is not installed`);
|
|
828
843
|
}
|
|
829
844
|
const customTools = askTool
|
|
830
845
|
? [emitTool, askTool, artifactTool]
|
|
831
846
|
: [emitTool, artifactTool];
|
|
847
|
+
const tools = resolveStageToolNames(emitDef.name, artifactDef.name, askDef?.name ?? "ask_operator", gateKinds);
|
|
848
|
+
if (attached.extensionFactories !== undefined) {
|
|
849
|
+
tools.push(...collectMcpExtensionToolNames(loader));
|
|
850
|
+
}
|
|
832
851
|
return {
|
|
833
852
|
sessionManager,
|
|
834
853
|
modelRuntime,
|
|
835
854
|
settingsManager,
|
|
836
855
|
loader,
|
|
837
|
-
tools
|
|
856
|
+
tools,
|
|
838
857
|
customTools,
|
|
839
858
|
emitDefName: emitDef.name,
|
|
840
859
|
...(askDef ? { askOperatorDefName: askDef.name } : {}),
|
|
@@ -843,10 +862,16 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
843
862
|
restoreProvider,
|
|
844
863
|
capture,
|
|
845
864
|
askWaitChannel,
|
|
865
|
+
connecting: attached.connecting,
|
|
866
|
+
cancelConnecting: attached.cancel,
|
|
846
867
|
};
|
|
847
868
|
}
|
|
848
869
|
catch (err) {
|
|
870
|
+
attached?.cancel?.();
|
|
849
871
|
restoreProvider?.();
|
|
872
|
+
if (err instanceof StageMcpError) {
|
|
873
|
+
return { ok: false, reason: err.message };
|
|
874
|
+
}
|
|
850
875
|
throw err;
|
|
851
876
|
}
|
|
852
877
|
}
|
|
@@ -886,6 +911,9 @@ export async function reconstructStageSessionForAnswer(input, answer) {
|
|
|
886
911
|
});
|
|
887
912
|
session = created.session;
|
|
888
913
|
await session.bindExtensions({});
|
|
914
|
+
if (wiring.connecting !== undefined) {
|
|
915
|
+
await wiring.connecting;
|
|
916
|
+
}
|
|
889
917
|
const resolved = resolveCliModel({
|
|
890
918
|
cliModel: input.stage.model,
|
|
891
919
|
modelRuntime: wiring.modelRuntime,
|
|
@@ -913,6 +941,7 @@ export async function reconstructStageSessionForAnswer(input, answer) {
|
|
|
913
941
|
};
|
|
914
942
|
}
|
|
915
943
|
catch (err) {
|
|
944
|
+
wiring.cancelConnecting?.();
|
|
916
945
|
await shutdownSession(session);
|
|
917
946
|
wiring.restoreProvider?.();
|
|
918
947
|
if (err instanceof StageSessionReconstructError)
|
|
@@ -934,7 +963,20 @@ async function bindStageSession(input, sessionManager, wiring) {
|
|
|
934
963
|
});
|
|
935
964
|
const session = created.session;
|
|
936
965
|
ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
|
|
937
|
-
|
|
966
|
+
try {
|
|
967
|
+
await session.bindExtensions({});
|
|
968
|
+
if (wiring.connecting !== undefined) {
|
|
969
|
+
await wiring.connecting;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
catch (err) {
|
|
973
|
+
wiring.cancelConnecting?.();
|
|
974
|
+
await shutdownSession(session);
|
|
975
|
+
if (err instanceof StageMcpError) {
|
|
976
|
+
return { ok: false, reason: err.message };
|
|
977
|
+
}
|
|
978
|
+
throw err;
|
|
979
|
+
}
|
|
938
980
|
const resolved = resolveCliModel({
|
|
939
981
|
cliModel: input.stage.model,
|
|
940
982
|
modelRuntime: wiring.modelRuntime,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type EventBus, type InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
|
|
3
|
+
export declare const STAGEFLOW_PI_MCP_EXTENSION_NAME = "stageflow-mcp";
|
|
4
|
+
type IsolatedMcpServerEntry = Record<string, unknown> & {
|
|
5
|
+
lifecycle: "lazy" | "eager";
|
|
6
|
+
directTools: true;
|
|
7
|
+
};
|
|
8
|
+
export type IsolatedMcpAttachOptions = {
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
lifecycle?: IsolatedMcpServerEntry["lifecycle"];
|
|
11
|
+
};
|
|
12
|
+
export type IsolatedMcpAttach = {
|
|
13
|
+
extensionFactories: InlineExtension[] | undefined;
|
|
14
|
+
eventBus: EventBus | undefined;
|
|
15
|
+
connecting: Promise<void> | undefined;
|
|
16
|
+
cancel?: () => void;
|
|
17
|
+
};
|
|
18
|
+
export declare function emitIsolatedMcpStatus(eventBus: EventBus, servers: ReadonlyArray<{
|
|
19
|
+
name: string;
|
|
20
|
+
status: string;
|
|
21
|
+
}>): void;
|
|
22
|
+
export declare function attachIsolatedMcp(snapshot?: ResolvedMcpServers, options?: IsolatedMcpAttachOptions): Promise<IsolatedMcpAttach>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createEventBus, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { StageMcpError, } from "../config/resolveStageMcpServers.js";
|
|
3
|
+
export const STAGEFLOW_PI_MCP_EXTENSION_NAME = "stageflow-mcp";
|
|
4
|
+
const MCP_STATUS_EVENT = "pi-mcp-adapter/status/v1";
|
|
5
|
+
const DEFAULT_ISOLATED_MCP_CONNECT_TIMEOUT_MS = 5_000;
|
|
6
|
+
const STATUS_POLL_MS = 50;
|
|
7
|
+
const PI_MCP_ADAPTER_SPEC = "pi-mcp-adapter";
|
|
8
|
+
const ISOLATED_MCP_SETTINGS = {
|
|
9
|
+
directTools: true,
|
|
10
|
+
elicitation: false,
|
|
11
|
+
hostConfigDiscovery: "off",
|
|
12
|
+
};
|
|
13
|
+
const SUCCESS_STATUSES = new Set([
|
|
14
|
+
"connected",
|
|
15
|
+
"cached",
|
|
16
|
+
]);
|
|
17
|
+
const FAILURE_STATUSES = new Set([
|
|
18
|
+
"failed",
|
|
19
|
+
"needs-auth",
|
|
20
|
+
"disabled",
|
|
21
|
+
]);
|
|
22
|
+
const EMPTY_ATTACH = {
|
|
23
|
+
extensionFactories: undefined,
|
|
24
|
+
eventBus: undefined,
|
|
25
|
+
connecting: undefined,
|
|
26
|
+
};
|
|
27
|
+
let cachedCreateMcpAdapter;
|
|
28
|
+
function toIsolatedMcpConfig(snapshot, lifecycle = "lazy") {
|
|
29
|
+
const mcpServers = {};
|
|
30
|
+
for (const [name, entry] of Object.entries(snapshot)) {
|
|
31
|
+
const isolated = {
|
|
32
|
+
...entry,
|
|
33
|
+
lifecycle,
|
|
34
|
+
directTools: true,
|
|
35
|
+
};
|
|
36
|
+
if (typeof entry.cwd === "string") {
|
|
37
|
+
isolated.cwd = entry.cwd;
|
|
38
|
+
}
|
|
39
|
+
mcpServers[name] = isolated;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
mcpServers,
|
|
43
|
+
settings: { ...ISOLATED_MCP_SETTINGS },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function loadCreateMcpAdapter() {
|
|
47
|
+
if (cachedCreateMcpAdapter !== undefined) {
|
|
48
|
+
return cachedCreateMcpAdapter;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const mod = (await import(PI_MCP_ADAPTER_SPEC));
|
|
52
|
+
if (typeof mod.createMcpAdapter === "function") {
|
|
53
|
+
cachedCreateMcpAdapter = mod.createMcpAdapter;
|
|
54
|
+
return cachedCreateMcpAdapter;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Node does not type-strip .ts under node_modules.
|
|
59
|
+
}
|
|
60
|
+
const { createJiti } = await import("jiti/static");
|
|
61
|
+
const jiti = createJiti(import.meta.url);
|
|
62
|
+
const mod = (await jiti.import(PI_MCP_ADAPTER_SPEC));
|
|
63
|
+
cachedCreateMcpAdapter = mod.createMcpAdapter;
|
|
64
|
+
return cachedCreateMcpAdapter;
|
|
65
|
+
}
|
|
66
|
+
function isRuntimeStatus(value) {
|
|
67
|
+
return (value === "connected" ||
|
|
68
|
+
value === "cached" ||
|
|
69
|
+
value === "failed" ||
|
|
70
|
+
value === "needs-auth" ||
|
|
71
|
+
value === "not-connected" ||
|
|
72
|
+
value === "disabled");
|
|
73
|
+
}
|
|
74
|
+
function asIsolatedMcpStatusSnapshot(data) {
|
|
75
|
+
if (data === null || typeof data !== "object")
|
|
76
|
+
return undefined;
|
|
77
|
+
const servers = data.servers;
|
|
78
|
+
if (!Array.isArray(servers))
|
|
79
|
+
return undefined;
|
|
80
|
+
const parsed = [];
|
|
81
|
+
for (const entry of servers) {
|
|
82
|
+
if (entry === null || typeof entry !== "object")
|
|
83
|
+
continue;
|
|
84
|
+
const name = entry.name;
|
|
85
|
+
const status = entry.status;
|
|
86
|
+
if (typeof name !== "string" || typeof status !== "string")
|
|
87
|
+
continue;
|
|
88
|
+
if (!isRuntimeStatus(status))
|
|
89
|
+
continue;
|
|
90
|
+
parsed.push({ name, status });
|
|
91
|
+
}
|
|
92
|
+
return { servers: parsed };
|
|
93
|
+
}
|
|
94
|
+
function mcpStatusSourceFromEvents(events) {
|
|
95
|
+
return {
|
|
96
|
+
subscribe(listener) {
|
|
97
|
+
const handler = (data) => {
|
|
98
|
+
const snapshot = asIsolatedMcpStatusSnapshot(data);
|
|
99
|
+
if (snapshot)
|
|
100
|
+
listener(snapshot);
|
|
101
|
+
};
|
|
102
|
+
const unsubscribe = events.on(MCP_STATUS_EVENT, handler);
|
|
103
|
+
if (typeof unsubscribe === "function")
|
|
104
|
+
return unsubscribe;
|
|
105
|
+
return () => {
|
|
106
|
+
events.off?.(MCP_STATUS_EVENT, handler);
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function connectFailed(name, status) {
|
|
112
|
+
return new StageMcpError(`MCP server "${name}" failed to connect (status: ${status})`, "connect_failed");
|
|
113
|
+
}
|
|
114
|
+
function timeoutError(pendingNames) {
|
|
115
|
+
if (pendingNames.length === 1) {
|
|
116
|
+
return connectFailed(pendingNames[0], "timed out");
|
|
117
|
+
}
|
|
118
|
+
const listed = pendingNames.map((name) => `"${name}"`).join(", ");
|
|
119
|
+
return new StageMcpError(`MCP servers ${listed} failed to connect (status: timed out)`, "connect_failed");
|
|
120
|
+
}
|
|
121
|
+
function evaluateConnectStatus(serverNames, snapshot) {
|
|
122
|
+
if (snapshot === undefined) {
|
|
123
|
+
return { kind: "wait", pending: [...serverNames] };
|
|
124
|
+
}
|
|
125
|
+
const byName = new Map(snapshot.servers.map((entry) => [entry.name, entry]));
|
|
126
|
+
const pending = [];
|
|
127
|
+
for (const name of serverNames) {
|
|
128
|
+
const entry = byName.get(name);
|
|
129
|
+
if (entry === undefined || entry.status === "not-connected") {
|
|
130
|
+
pending.push(name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (FAILURE_STATUSES.has(entry.status)) {
|
|
134
|
+
return { kind: "fail", name, status: entry.status };
|
|
135
|
+
}
|
|
136
|
+
if (!SUCCESS_STATUSES.has(entry.status)) {
|
|
137
|
+
pending.push(name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (pending.length > 0) {
|
|
141
|
+
return { kind: "wait", pending };
|
|
142
|
+
}
|
|
143
|
+
return { kind: "ok" };
|
|
144
|
+
}
|
|
145
|
+
function waitForIsolatedMcpConnect(snapshot, source, options) {
|
|
146
|
+
const serverNames = Object.keys(snapshot);
|
|
147
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_ISOLATED_MCP_CONNECT_TIMEOUT_MS;
|
|
148
|
+
let lastSnapshot = source.read?.();
|
|
149
|
+
let finish = () => { };
|
|
150
|
+
const connecting = new Promise((resolve, reject) => {
|
|
151
|
+
let settled = false;
|
|
152
|
+
let unsubscribe;
|
|
153
|
+
let poll;
|
|
154
|
+
let timer;
|
|
155
|
+
finish = (error) => {
|
|
156
|
+
if (settled)
|
|
157
|
+
return;
|
|
158
|
+
settled = true;
|
|
159
|
+
unsubscribe?.();
|
|
160
|
+
if (poll !== undefined)
|
|
161
|
+
clearInterval(poll);
|
|
162
|
+
if (timer !== undefined)
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
if (error)
|
|
165
|
+
reject(error);
|
|
166
|
+
else
|
|
167
|
+
resolve();
|
|
168
|
+
};
|
|
169
|
+
const consider = (next) => {
|
|
170
|
+
if (next !== undefined)
|
|
171
|
+
lastSnapshot = next;
|
|
172
|
+
const result = evaluateConnectStatus(serverNames, lastSnapshot);
|
|
173
|
+
if (result.kind === "ok") {
|
|
174
|
+
finish();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (result.kind === "fail") {
|
|
178
|
+
finish(connectFailed(result.name, result.status));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
consider(lastSnapshot);
|
|
182
|
+
if (settled)
|
|
183
|
+
return;
|
|
184
|
+
unsubscribe = source.subscribe?.((next) => consider(next));
|
|
185
|
+
consider(source.read?.());
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
if (source.read) {
|
|
189
|
+
poll = setInterval(() => consider(source.read?.()), STATUS_POLL_MS);
|
|
190
|
+
}
|
|
191
|
+
timer = setTimeout(() => {
|
|
192
|
+
const pending = evaluateConnectStatus(serverNames, lastSnapshot);
|
|
193
|
+
finish(timeoutError(pending.kind === "wait" ? pending.pending : serverNames));
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
connecting,
|
|
198
|
+
cancel() {
|
|
199
|
+
void connecting.then(() => undefined, () => undefined);
|
|
200
|
+
finish(new StageMcpError("MCP connect wait cancelled", "connect_failed"));
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export function emitIsolatedMcpStatus(eventBus, servers) {
|
|
205
|
+
eventBus.emit(MCP_STATUS_EVENT, { servers });
|
|
206
|
+
}
|
|
207
|
+
export async function attachIsolatedMcp(snapshot, options) {
|
|
208
|
+
if (snapshot === undefined || Object.keys(snapshot).length === 0) {
|
|
209
|
+
return EMPTY_ATTACH;
|
|
210
|
+
}
|
|
211
|
+
const createMcpAdapter = await loadCreateMcpAdapter();
|
|
212
|
+
const extensionFactories = [
|
|
213
|
+
{
|
|
214
|
+
name: STAGEFLOW_PI_MCP_EXTENSION_NAME,
|
|
215
|
+
factory: createMcpAdapter({
|
|
216
|
+
config: toIsolatedMcpConfig(snapshot, options?.lifecycle ?? "lazy"),
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
const eventBus = createEventBus();
|
|
221
|
+
const { connecting, cancel } = waitForIsolatedMcpConnect(snapshot, mcpStatusSourceFromEvents(eventBus), options);
|
|
222
|
+
return { extensionFactories, eventBus, connecting, cancel };
|
|
223
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const PROJECT_MCP_PROBE_TIMEOUT_MS = 30000;
|
|
2
|
+
export type ProjectMcpProbeStatus = "connected" | "needs_auth" | "connect_failed" | "unresolved_var" | "invalid_config" | "missing_catalog" | "cancelled";
|
|
3
|
+
export type ProjectMcpProbeResult = {
|
|
4
|
+
name: string;
|
|
5
|
+
status: ProjectMcpProbeStatus;
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
export type ProbeProjectMcpServerOptions = {
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
name: string;
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare function probeProjectMcpServer(options: ProbeProjectMcpServerOptions): Promise<ProjectMcpProbeResult>;
|