@polymorphism-tech/morph-spec 8.29.0 → 8.31.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.
Files changed (170) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +13 -2
  3. package/bin/morph-spec.js +48 -0
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -0
  9. package/framework/MORPH.md +2 -1
  10. package/framework/agents.json +6 -6
  11. package/framework/commands/morph-status.md +1 -1
  12. package/framework/docs/CLI.md +18 -0
  13. package/framework/evals/rubrics/code-quality.md +17 -0
  14. package/framework/evals/rubrics/condensed.md +3 -1
  15. package/framework/hooks/claude-code/statusline.sh +11 -11
  16. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  17. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  18. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  19. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  20. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  21. package/framework/hooks/shared/phase-utils.js +2 -0
  22. package/framework/hooks/shared/stdin-reader.js +26 -26
  23. package/framework/mcp/registry.json +39 -4
  24. package/framework/rubrics/backend.json +2 -2
  25. package/framework/rubrics/default.json +2 -2
  26. package/framework/rubrics/frontend.json +2 -2
  27. package/framework/schemas/tasks.schema.json +25 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +12 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +12 -2
  35. package/framework/skills/core/morph-plan/SKILL.md +45 -3
  36. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  37. package/framework/skills/core/morph-review/SKILL.md +11 -3
  38. package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
  39. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  40. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  41. package/framework/standards/ai-agents/agent-session.md +160 -160
  42. package/framework/standards/ai-agents/batching.md +193 -193
  43. package/framework/standards/ai-agents/context-providers.md +188 -188
  44. package/framework/standards/ai-agents/mcp-server.md +250 -250
  45. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  46. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  47. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  48. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  49. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  50. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  51. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  52. package/framework/standards/ai-agents/production.md +231 -231
  53. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  54. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  55. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  56. package/framework/standards/ai-agents/structured-output.md +249 -249
  57. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  58. package/framework/templates/.idea/morph-templates.xml +92 -0
  59. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  60. package/framework/templates/IDE-SNIPPETS.md +262 -262
  61. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  62. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  63. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  64. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  65. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  66. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  67. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  68. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  69. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  70. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  71. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  72. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  73. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  74. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  75. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  76. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  77. package/framework/templates/code/typescript/contracts.ts +167 -167
  78. package/framework/templates/context/ARCHITECTURE.md +20 -0
  79. package/framework/templates/docs/clarifications.md +253 -253
  80. package/framework/templates/docs/schema-analysis.md +119 -119
  81. package/framework/templates/docs/ui-components.md +52 -52
  82. package/framework/templates/docs/ui-design-system.md +76 -76
  83. package/framework/templates/docs/ui-flows.md +167 -167
  84. package/framework/templates/docs/ui-mockups.md +98 -98
  85. package/framework/templates/feature/decisions.md +122 -122
  86. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  87. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  88. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  89. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  90. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  91. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  92. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  93. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  94. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  95. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  96. package/framework/templates/infrastructure/github/README.md +56 -56
  97. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  98. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  99. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  100. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  101. package/framework/templates/integrations/asaas-client.cs +387 -387
  102. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  103. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  104. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  105. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  106. package/framework/templates/project/validate.js +120 -120
  107. package/framework/templates/state.template.json +17 -17
  108. package/framework/templates/ui/design-system.css +226 -226
  109. package/package.json +1 -1
  110. package/src/commands/dashboard/dashboard.js +53 -53
  111. package/src/commands/graph/affected.js +89 -0
  112. package/src/commands/graph/build.js +94 -0
  113. package/src/commands/graph/explain.js +96 -0
  114. package/src/commands/graph/status.js +93 -0
  115. package/src/commands/index.js +13 -13
  116. package/src/commands/project/checkpoint.js +209 -209
  117. package/src/commands/project/doctor.js +11 -0
  118. package/src/core/paths/output-schema.js +7 -0
  119. package/src/core/state/phase-state-machine.js +197 -197
  120. package/src/core/templates/template-renderer.js +477 -477
  121. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  122. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  123. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  124. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  125. package/src/dashboard/app/main.jsx +13 -13
  126. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  127. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  128. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  129. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  130. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  131. package/src/dashboard/app/pages/Standards.jsx +332 -332
  132. package/src/dashboard/app/styles/index.css +47 -47
  133. package/src/dashboard/index.html +12 -12
  134. package/src/dashboard/package.json +24 -24
  135. package/src/dashboard/scanner/phases-scanner.js +15 -15
  136. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  137. package/src/dashboard/scanner/rules-scanner.js +37 -37
  138. package/src/dashboard/scanner/skills-scanner.js +43 -43
  139. package/src/dashboard/scanner/squads-scanner.js +13 -13
  140. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  141. package/src/dashboard/vite.config.js +24 -24
  142. package/src/lib/detectors/design-system-detector.js +188 -188
  143. package/src/lib/detectors/index.js +217 -217
  144. package/src/lib/generators/architecture-scanner.js +45 -4
  145. package/src/lib/generators/design-system-generator.js +508 -508
  146. package/src/lib/generators/settings-generator.js +209 -209
  147. package/src/lib/graph/god-nodes.js +75 -0
  148. package/src/lib/graph/graph-health.js +61 -0
  149. package/src/lib/graph/graph-loader.js +86 -0
  150. package/src/lib/graph/graphify-runtime.js +100 -0
  151. package/src/lib/graph/graphifyignore.js +86 -0
  152. package/src/lib/graph/sanitize.js +280 -0
  153. package/src/lib/hooks/hook-executor.js +167 -167
  154. package/src/lib/installers/mcp-installer.js +719 -719
  155. package/src/lib/tasks/task-classifier.js +30 -30
  156. package/src/lib/tasks/task-parser.js +209 -209
  157. package/src/lib/tasks/tasks-json-parser.js +26 -0
  158. package/src/lib/validators/checkpoint-persistence.js +39 -39
  159. package/src/lib/validators/css/css-validator.js +340 -340
  160. package/src/lib/validators/reuse/index.js +10 -0
  161. package/src/lib/validators/reuse/reuse-validator.js +324 -0
  162. package/src/lib/validators/reuse/symbol-scanner.js +144 -0
  163. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  164. package/src/lib/validators/shared/issue-counter.js +14 -14
  165. package/src/lib/validators/validation-runner.js +40 -1
  166. package/src/scripts/setup-infra.js +60 -1
  167. package/src/utils/agent-id-mapper.js +11 -11
  168. package/src/utils/claude-settings-manager.js +300 -300
  169. package/src/utils/file-copier.js +40 -0
  170. package/src/utils/skills-installer.js +187 -187
@@ -1,719 +1,719 @@
1
- /**
2
- * MCP Installer
3
- *
4
- * Core logic for installing and configuring MCP servers during morph-spec init
5
- * and via `morph-spec mcp setup`. Handles auto-installable (credential-free)
6
- * MCPs, prerequisite checking, credential collection, and setup guidance.
7
- */
8
-
9
- import { join } from 'path';
10
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
- import { execSync, spawn } from 'child_process';
12
- import { homedir } from 'os';
13
- import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
-
15
- /**
16
- * Load the enhanced MCP registry (v3.0.0)
17
- * @returns {Object} Registry with install blocks
18
- */
19
- export function loadMcpRegistry() {
20
- const registryPath = join(
21
- import.meta.dirname, '..', '..', '..', 'framework', 'mcp', 'registry.json'
22
- );
23
- const raw = readFileSync(registryPath, 'utf-8');
24
- return JSON.parse(raw);
25
- }
26
-
27
- /**
28
- * Filter MCPs by stack relevance
29
- * @param {Object} registry - Full MCP registry
30
- * @param {string} stack - Detected project stack (e.g., 'nextjs-neon', 'blazor')
31
- * @returns {Object} Filtered map of mcpName → mcpEntry
32
- */
33
- export function filterByStack(registry, stack) {
34
- const result = {};
35
- const normalizedStack = (stack || '').toLowerCase();
36
-
37
- for (const [name, entry] of Object.entries(registry.mcps)) {
38
- if (!entry.install) continue;
39
- const relevance = entry.install.stackRelevance || [];
40
-
41
- if (relevance.includes('*')) {
42
- result[name] = entry;
43
- } else if (normalizedStack && relevance.some(s => normalizedStack.includes(s))) {
44
- result[name] = entry;
45
- }
46
- }
47
-
48
- return result;
49
- }
50
-
51
- /**
52
- * Match detected MCP servers against the registry
53
- * @param {Array} existingServers - From detectClaudeConfig().mcpServers
54
- * @param {Object} registry - MCP registry
55
- * @returns {Object} Map of mcpName → { installed: true, source }
56
- */
57
- export function getInstalledMcps(existingServers, registry) {
58
- const installed = {};
59
- const registryNames = Object.keys(registry.mcps);
60
-
61
- for (const server of existingServers) {
62
- const serverName = server.name.toLowerCase();
63
- for (const regName of registryNames) {
64
- if (serverName.includes(regName)) {
65
- installed[regName] = { installed: true, source: server.source };
66
- }
67
- }
68
- }
69
-
70
- return installed;
71
- }
72
-
73
- /**
74
- * Check if prerequisites are met for an MCP entry
75
- * @param {Object} mcpEntry - MCP entry from registry
76
- * @returns {Array} Array of { name, met, installUrl } for each prerequisite
77
- */
78
- export function checkPrerequisites(mcpEntry) {
79
- const prereqs = mcpEntry.install?.prerequisites || [];
80
- const results = [];
81
-
82
- for (const prereq of prereqs) {
83
- let met = false;
84
- try {
85
- execSync(prereq.checkCommand, { stdio: 'ignore', timeout: 5000 });
86
- met = true;
87
- } catch {
88
- // Command failed or not found
89
- }
90
- results.push({
91
- name: prereq.name,
92
- met,
93
- installUrl: prereq.installUrl
94
- });
95
- }
96
-
97
- return results;
98
- }
99
-
100
- /**
101
- * Check if an MCP config uses remote HTTP transport (no local process).
102
- * @param {Object} config - MCP server config
103
- * @returns {boolean}
104
- */
105
- export function isRemoteMcp(config) {
106
- return config.type === 'http' || config.url != null;
107
- }
108
-
109
- /**
110
- * Patch MCP config for the current platform.
111
- * On Windows, npx must be invoked via `cmd /c` to avoid ENOENT errors.
112
- * Registry already stores cmd/c format; this handles legacy configs that still use npx directly.
113
- * Remote HTTP MCPs are returned as-is (no local process to patch).
114
- * @param {Object} config - MCP server config { command, args, env? } or { type: "http", url }
115
- * @returns {Object} Patched config (new object, original unmodified)
116
- */
117
- export function patchConfigForPlatform(config) {
118
- // Remote MCPs need no platform patching
119
- if (isRemoteMcp(config)) return config;
120
- if (config.command !== 'npx') return config;
121
- return {
122
- ...config,
123
- command: 'cmd',
124
- args: ['/c', 'npx', ...(config.args || [])]
125
- };
126
- }
127
-
128
- /**
129
- * Install auto-installable MCPs (no credentials, no prerequisites)
130
- * @param {string} targetPath - Project root directory
131
- * @param {Object} mcpsToInstall - Map of mcpName → mcpEntry
132
- * @returns {Promise<Object>} Result from installMcpServers
133
- */
134
- export async function installAutoMcps(targetPath, mcpsToInstall) {
135
- const servers = {};
136
-
137
- for (const [name, entry] of Object.entries(mcpsToInstall)) {
138
- let config = { ...entry.install.config };
139
- // Remote MCPs don't have env to strip
140
- if (!isRemoteMcp(config)) {
141
- // Only include env if it has actual values
142
- if (config.env && Object.values(config.env).every(v => !v)) {
143
- delete config.env;
144
- }
145
- }
146
- servers[name] = patchConfigForPlatform(config);
147
- }
148
-
149
- return installMcpServers(targetPath, servers);
150
- }
151
-
152
- /**
153
- * Install companion skills for MCPs that define them (e.g., Stitch agent skills).
154
- * Reads the `companionSkills` field from registry entries and runs the install command.
155
- * @param {Object} mcpsInstalled - Map of mcpName → mcpEntry that were just installed
156
- * @param {Function} exec - execSync function (for testing)
157
- * @returns {Array<{name: string, repo: string, success: boolean}>}
158
- */
159
- export function installCompanionSkills(mcpsInstalled, exec = execSync) {
160
- const results = [];
161
-
162
- for (const [name, entry] of Object.entries(mcpsInstalled)) {
163
- const companion = entry.companionSkills;
164
- if (!companion?.installCommand) continue;
165
-
166
- try {
167
- exec(companion.installCommand, { stdio: 'inherit', timeout: 30000 });
168
- results.push({ name, repo: companion.repo, success: true });
169
- } catch {
170
- results.push({ name, repo: companion.repo, success: false });
171
- }
172
- }
173
-
174
- return results;
175
- }
176
-
177
- /**
178
- * Generate the Stitch OAuth proxy template script.
179
- * Cross-platform (Windows gcloud.cmd + Unix gcloud), fetch()-based,
180
- * with readline, session tracking, and graceful shutdown.
181
- * @param {string} gcpProject - GCP project ID for x-goog-user-project header
182
- * @returns {string} CJS proxy script content
183
- */
184
- export function getStitchProxyTemplate(gcpProject) {
185
- return `#!/usr/bin/env node
186
- /**
187
- * Stitch MCP stdio-to-HTTP proxy with Google auth
188
- * Generated by morph-spec mcp setup stitch
189
- */
190
- const { execSync } = require('child_process');
191
- const { createInterface } = require('readline');
192
-
193
- const STITCH_URL = 'https://stitch.googleapis.com/mcp';
194
- const PROJECT_ID = '${gcpProject}';
195
-
196
- // Cross-platform gcloud path: Windows uses gcloud.cmd from Cloud SDK install
197
- const GCLOUD = process.platform === 'win32'
198
- ? (process.env.LOCALAPPDATA || '') + '/Google/Cloud SDK/google-cloud-sdk/bin/gcloud.cmd'
199
- : 'gcloud';
200
-
201
- let cachedToken = null;
202
- let tokenExpiry = 0;
203
- let pendingRequests = 0;
204
- let stdinClosed = false;
205
- let sessionId = null;
206
-
207
- function getToken() {
208
- if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
209
- try {
210
- cachedToken = execSync(\`"\${GCLOUD}" auth application-default print-access-token\`, {
211
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
212
- }).trim();
213
- tokenExpiry = Date.now() + 50 * 60 * 1000; // 50-minute TTL
214
- return cachedToken;
215
- } catch {
216
- return null;
217
- }
218
- }
219
-
220
- function maybeExit() {
221
- if (stdinClosed && pendingRequests === 0) process.exit(0);
222
- }
223
-
224
- async function handleMessage(line) {
225
- let msg;
226
- try { msg = JSON.parse(line); } catch { return; }
227
-
228
- // Notifications (no id) don't need responses
229
- if (msg.id === undefined && msg.method === 'notifications/initialized') return;
230
-
231
- const token = getToken();
232
- if (!token) {
233
- if (msg.id !== undefined) {
234
- process.stdout.write(JSON.stringify({
235
- jsonrpc: '2.0', id: msg.id,
236
- error: { code: -32000, message: 'Failed to get gcloud auth token. Run: gcloud auth application-default login' }
237
- }) + '\\n');
238
- }
239
- return;
240
- }
241
-
242
- const headers = {
243
- 'Content-Type': 'application/json',
244
- 'Authorization': \`Bearer \${token}\`,
245
- 'x-goog-user-project': PROJECT_ID,
246
- };
247
- if (sessionId) headers['mcp-session-id'] = sessionId;
248
-
249
- pendingRequests++;
250
- try {
251
- const res = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
252
-
253
- // Track sticky session
254
- const sid = res.headers.get('mcp-session-id');
255
- if (sid) sessionId = sid;
256
-
257
- // Retry once on 401 (expired token)
258
- if (res.status === 401) {
259
- cachedToken = null;
260
- tokenExpiry = 0;
261
- const retryToken = getToken();
262
- if (retryToken) {
263
- headers['Authorization'] = \`Bearer \${retryToken}\`;
264
- const retryRes = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
265
- const retryText = await retryRes.text();
266
- if (retryText.trim()) {
267
- process.stdout.write(retryText.endsWith('\\n') ? retryText : retryText + '\\n');
268
- }
269
- return;
270
- }
271
- }
272
-
273
- const text = await res.text();
274
- if (text.trim()) {
275
- process.stdout.write(text.endsWith('\\n') ? text : text + '\\n');
276
- }
277
- } catch (err) {
278
- if (msg.id !== undefined) {
279
- process.stdout.write(JSON.stringify({
280
- jsonrpc: '2.0', id: msg.id,
281
- error: { code: -32000, message: \`Stitch API error: \${err.message}\` }
282
- }) + '\\n');
283
- }
284
- } finally {
285
- pendingRequests--;
286
- maybeExit();
287
- }
288
- }
289
-
290
- const rl = createInterface({ input: process.stdin });
291
- rl.on('line', handleMessage);
292
- rl.on('close', () => { stdinClosed = true; maybeExit(); });
293
- `;
294
- }
295
-
296
- /**
297
- * Generate and write the Stitch OAuth proxy script to the target project.
298
- * @param {string} targetPath - Project root directory
299
- * @param {string} gcpProject - GCP project ID
300
- * @returns {{ proxyPath: string }} Path to the generated proxy script
301
- */
302
- export function generateStitchProxy(targetPath, gcpProject) {
303
- const claudeDir = join(targetPath, '.claude');
304
- if (!existsSync(claudeDir)) {
305
- mkdirSync(claudeDir, { recursive: true });
306
- }
307
-
308
- const proxyPath = join(claudeDir, 'stitch-proxy.cjs');
309
- writeFileSync(proxyPath, getStitchProxyTemplate(gcpProject), 'utf-8');
310
-
311
- return { proxyPath };
312
- }
313
-
314
- /**
315
- * Resolve the gcloud CLI binary path.
316
- * Checks: system PATH → bundled at ~/.stitch-mcp/ → null.
317
- * @param {Function} exec - execSync function (for testing)
318
- * @returns {{ gcloudPath: string|null, source: string }}
319
- */
320
- export function findGcloud(exec = execSync) {
321
- // Try system PATH first
322
- try {
323
- exec('gcloud --version', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 });
324
- return { gcloudPath: 'gcloud', source: 'system' };
325
- } catch { /* not on PATH */ }
326
-
327
- // Windows: try Cloud SDK default install location
328
- if (process.platform === 'win32') {
329
- const winPath = join(process.env.LOCALAPPDATA || '', 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd');
330
- if (existsSync(winPath)) {
331
- return { gcloudPath: winPath, source: 'windows-sdk' };
332
- }
333
- }
334
-
335
- // Try bundled at ~/.stitch-mcp/
336
- const bundledDir = join(homedir(), '.stitch-mcp');
337
- const bundledBin = process.platform === 'win32'
338
- ? join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud.cmd')
339
- : join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud');
340
- if (existsSync(bundledBin)) {
341
- return { gcloudPath: bundledBin, source: 'bundled' };
342
- }
343
-
344
- return { gcloudPath: null, source: 'not-found' };
345
- }
346
-
347
- /**
348
- * Fully automated Stitch MCP setup orchestrator.
349
- *
350
- * Steps:
351
- * 1. Find gcloud CLI
352
- * 2. Verify ADC credentials
353
- * 3. Detect GCP project
354
- * 4. Enable stitch.googleapis.com API
355
- * 5. Generate proxy script
356
- * 6. Install MCP config in .mcp.json
357
- * 7. Health-check the proxy
358
- *
359
- * @param {string} targetPath - Project root directory
360
- * @param {Object} options
361
- * @param {string} [options.gcpProject] - GCP project ID (auto-detected if omitted)
362
- * @param {Function} [options._exec] - execSync override for testing
363
- * @returns {Promise<Object>} Step-by-step results
364
- */
365
- export async function setupStitch(targetPath, options = {}) {
366
- const exec = options._exec || execSync;
367
- const steps = [];
368
-
369
- // Step 1: Find gcloud
370
- const { gcloudPath, source: gcloudSource } = findGcloud(exec);
371
- if (!gcloudPath) {
372
- steps.push({ step: 'find-gcloud', success: false, error: 'gcloud CLI not found. Install from: https://cloud.google.com/sdk/docs/install' });
373
- return { success: false, steps };
374
- }
375
- steps.push({ step: 'find-gcloud', success: true, gcloudPath, source: gcloudSource });
376
-
377
- const gcloudCmd = gcloudPath.includes(' ') ? `"${gcloudPath}"` : gcloudPath;
378
-
379
- // Step 2: Verify ADC credentials
380
- try {
381
- exec(`${gcloudCmd} auth application-default print-access-token`, {
382
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000
383
- });
384
- steps.push({ step: 'verify-adc', success: true });
385
- } catch {
386
- steps.push({ step: 'verify-adc', success: false, error: 'No ADC credentials. Run: gcloud auth application-default login' });
387
- return { success: false, steps };
388
- }
389
-
390
- // Step 3: Detect GCP project
391
- let gcpProject = options.gcpProject;
392
- if (!gcpProject) {
393
- // Try config.json
394
- const configPath = join(targetPath, '.morph', 'config', 'config.json');
395
- if (existsSync(configPath)) {
396
- try {
397
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
398
- gcpProject = config.gcpProject || config.project?.gcpProject;
399
- } catch { /* invalid config */ }
400
- }
401
- }
402
- if (!gcpProject) {
403
- // Try gcloud config
404
- try {
405
- gcpProject = exec(`${gcloudCmd} config get-value project`, {
406
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
407
- }).trim();
408
- if (!gcpProject || gcpProject === '(unset)') gcpProject = null;
409
- } catch { /* gcloud config failed */ }
410
- }
411
- if (!gcpProject) {
412
- steps.push({ step: 'detect-project', success: false, error: 'Could not detect GCP project. Pass it explicitly or set via: gcloud config set project <id>' });
413
- return { success: false, steps };
414
- }
415
- steps.push({ step: 'detect-project', success: true, gcpProject });
416
-
417
- // Step 4: Enable Stitch API
418
- try {
419
- exec(`${gcloudCmd} services enable stitch.googleapis.com --project=${gcpProject}`, {
420
- encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000
421
- });
422
- steps.push({ step: 'enable-api', success: true });
423
- } catch (err) {
424
- steps.push({ step: 'enable-api', success: false, error: `Failed to enable stitch API: ${err.message}` });
425
- // Non-fatal — API might already be enabled and user may lack permissions to re-enable
426
- }
427
-
428
- // Step 5: Generate proxy
429
- const { proxyPath } = generateStitchProxy(targetPath, gcpProject);
430
- steps.push({ step: 'generate-proxy', success: true, proxyPath });
431
-
432
- // Step 6: Install MCP config in .mcp.json
433
- const mcpResult = await installMcpServers(targetPath, {
434
- stitch: { type: 'stdio', command: 'node', args: ['.claude/stitch-proxy.cjs'] }
435
- });
436
- steps.push({ step: 'install-mcp', success: true, added: mcpResult.added, skipped: mcpResult.skipped });
437
-
438
- // Step 7: Health-check
439
- const healthResult = await healthCheckStitchProxy(proxyPath);
440
- steps.push({ step: 'health-check', ...healthResult });
441
-
442
- const allCriticalPassed = steps.every(s =>
443
- s.success || s.step === 'enable-api' || s.step === 'health-check'
444
- );
445
-
446
- return { success: allCriticalPassed, steps, gcpProject };
447
- }
448
-
449
- /**
450
- * Health-check the Stitch proxy by sending an MCP initialize handshake.
451
- * @param {string} proxyPath - Path to stitch-proxy.cjs
452
- * @returns {Promise<Object>} { success, protocolVersion?, error? }
453
- */
454
- export function healthCheckStitchProxy(proxyPath) {
455
- return new Promise((resolve) => {
456
- const initMsg = JSON.stringify({
457
- jsonrpc: '2.0',
458
- id: 1,
459
- method: 'initialize',
460
- params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'morph-spec', version: '1.0.0' } }
461
- });
462
-
463
- const child = spawn('node', [proxyPath], {
464
- stdio: ['pipe', 'pipe', 'pipe'],
465
- timeout: 20000,
466
- });
467
-
468
- let output = '';
469
- let timedOut = false;
470
-
471
- const timer = setTimeout(() => {
472
- timedOut = true;
473
- child.kill();
474
- resolve({ success: false, error: 'Health-check timed out after 20s' });
475
- }, 20000);
476
-
477
- child.stdout.on('data', (data) => {
478
- output += data.toString();
479
- // Check if we got a response
480
- const lines = output.split('\n').filter(l => l.trim());
481
- for (const line of lines) {
482
- try {
483
- const resp = JSON.parse(line);
484
- if (resp.result?.protocolVersion) {
485
- clearTimeout(timer);
486
- child.kill();
487
- resolve({ success: true, protocolVersion: resp.result.protocolVersion });
488
- return;
489
- }
490
- if (resp.error) {
491
- clearTimeout(timer);
492
- child.kill();
493
- resolve({ success: false, error: resp.error.message || 'MCP error' });
494
- return;
495
- }
496
- } catch { /* not valid JSON yet */ }
497
- }
498
- });
499
-
500
- child.on('error', (err) => {
501
- if (!timedOut) {
502
- clearTimeout(timer);
503
- resolve({ success: false, error: err.message });
504
- }
505
- });
506
-
507
- child.on('close', (code) => {
508
- if (!timedOut) {
509
- clearTimeout(timer);
510
- resolve({ success: false, error: output.trim()
511
- ? `Proxy exited with code ${code} — unexpected output`
512
- : `Proxy exited with code ${code} and no output`
513
- });
514
- }
515
- });
516
-
517
- // Send the initialize message
518
- child.stdin.write(initMsg + '\n');
519
- child.stdin.end();
520
- });
521
- }
522
-
523
- /**
524
- * Install a single MCP with provided credentials
525
- * @param {string} targetPath - Project root directory
526
- * @param {string} name - MCP name
527
- * @param {Object} mcpEntry - MCP entry from registry
528
- * @param {Object} credentialValues - Map of envVar → value
529
- * @returns {Promise<Object>} Result from installMcpServers
530
- */
531
- export async function installMcpWithCredentials(targetPath, name, mcpEntry, credentialValues) {
532
- const config = patchConfigForPlatform({ ...mcpEntry.install.config });
533
- config.env = { ...config.env, ...credentialValues };
534
- return installMcpServers(targetPath, { [name]: config });
535
- }
536
-
537
- /**
538
- * Generate setup instructions for a credential-requiring MCP
539
- * @param {string} name - MCP name
540
- * @param {Object} mcpEntry - MCP entry from registry
541
- * @returns {Object} { configSnippet, credentialUrls, cliCommand }
542
- */
543
- export function generateSetupInstructions(name, mcpEntry) {
544
- const config = mcpEntry.install.config;
545
- const credentials = mcpEntry.install.credentials || [];
546
- const setupGuide = mcpEntry.install.setupGuide;
547
-
548
- // Remote HTTP MCPs — show claude mcp add command instead of config snippet
549
- if (isRemoteMcp(config)) {
550
- const configSnippet = JSON.stringify({ [name]: config }, null, 2);
551
- const credentialUrls = credentials.map(c => ({
552
- name: c.name,
553
- envVar: c.envVar,
554
- helpUrl: c.helpUrl
555
- }));
556
- return {
557
- configSnippet,
558
- credentialUrls,
559
- cliCommand: setupGuide || `claude mcp add --transport http ${name} ${config.url}`,
560
- isRemote: true
561
- };
562
- }
563
-
564
- // Build the config snippet with placeholder values
565
- const snippetConfig = { ...config };
566
- if (credentials.length > 0) {
567
- snippetConfig.env = {};
568
- for (const cred of credentials) {
569
- snippetConfig.env[cred.envVar] = cred.secret ? 'YOUR_KEY' : 'YOUR_VALUE';
570
- }
571
- }
572
-
573
- const configSnippet = JSON.stringify({ [name]: snippetConfig }, null, 2);
574
-
575
- const credentialUrls = credentials.map(c => ({
576
- name: c.name,
577
- envVar: c.envVar,
578
- helpUrl: c.helpUrl
579
- }));
580
-
581
- return {
582
- configSnippet,
583
- credentialUrls,
584
- cliCommand: `morph-spec mcp setup ${name}`
585
- };
586
- }
587
-
588
- /**
589
- * Full orchestrator for MCP setup during init or `mcp setup`
590
- * @param {string} targetPath - Project root directory
591
- * @param {string} stack - Detected project stack
592
- * @param {Array} existingMcps - From detectClaudeConfig().mcpServers
593
- * @param {Object} options
594
- * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
595
- * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
596
- */
597
- export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
598
- const registry = loadMcpRegistry();
599
- const relevant = filterByStack(registry, stack);
600
- const installed = getInstalledMcps(existingMcps || [], registry);
601
-
602
- const autoInstallable = {};
603
- const alreadyInstalled = {};
604
- const needsManualSetup = {};
605
- const notRelevant = {};
606
- const prerequisitesMissing = {};
607
-
608
- // Categorize all MCPs
609
- for (const [name, entry] of Object.entries(registry.mcps)) {
610
- if (!entry.install) continue;
611
-
612
- // Check if already installed
613
- if (installed[name]) {
614
- alreadyInstalled[name] = { entry, source: installed[name].source };
615
- continue;
616
- }
617
-
618
- // Check if relevant to stack
619
- if (!relevant[name]) {
620
- notRelevant[name] = entry;
621
- continue;
622
- }
623
-
624
- // Check prerequisites
625
- const prereqResults = checkPrerequisites(entry);
626
- const unmetPrereqs = prereqResults.filter(p => !p.met);
627
- if (unmetPrereqs.length > 0) {
628
- prerequisitesMissing[name] = { entry, unmet: unmetPrereqs };
629
- continue;
630
- }
631
-
632
- // Categorize by install type
633
- if (entry.install.autoInstall) {
634
- autoInstallable[name] = entry;
635
- } else if (!options.autoOnly) {
636
- needsManualSetup[name] = entry;
637
- }
638
- }
639
-
640
- return {
641
- registry,
642
- autoInstallable,
643
- alreadyInstalled,
644
- needsManualSetup,
645
- notRelevant,
646
- prerequisitesMissing
647
- };
648
- }
649
-
650
- /**
651
- * Format MCP status table for display
652
- * @param {Object} orchestration - Result from orchestrateMcpSetup
653
- * @param {Object} justInstalled - Map of names just installed during this run
654
- * @returns {Array<Object>} Array of { name, status, detail }
655
- */
656
- export function formatMcpStatusTable(orchestration, justInstalled = {}) {
657
- const rows = [];
658
-
659
- const { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing, registry } = orchestration;
660
-
661
- // Installed during this run
662
- for (const [name, entry] of Object.entries(justInstalled)) {
663
- const mcpEntry = registry.mcps[name];
664
- rows.push({
665
- name,
666
- status: 'installed',
667
- detail: mcpEntry ? mcpEntry.usage : ''
668
- });
669
- }
670
-
671
- // Auto-installable but not yet installed (and not just-installed)
672
- for (const [name, entry] of Object.entries(autoInstallable)) {
673
- if (justInstalled[name]) continue;
674
- rows.push({
675
- name,
676
- status: 'available',
677
- detail: `Run: morph-spec mcp setup --auto`
678
- });
679
- }
680
-
681
- // Already installed
682
- for (const [name, { source }] of Object.entries(alreadyInstalled)) {
683
- rows.push({
684
- name,
685
- status: 'already_configured',
686
- detail: `(${source})`
687
- });
688
- }
689
-
690
- // Needs manual setup
691
- for (const [name, entry] of Object.entries(needsManualSetup)) {
692
- rows.push({
693
- name,
694
- status: 'needs_setup',
695
- detail: `Run: morph-spec mcp setup ${name}`
696
- });
697
- }
698
-
699
- // Prerequisites missing
700
- for (const [name, { unmet }] of Object.entries(prerequisitesMissing)) {
701
- const missing = unmet.map(p => p.name).join(', ');
702
- rows.push({
703
- name,
704
- status: 'prereq_missing',
705
- detail: `Missing: ${missing}`
706
- });
707
- }
708
-
709
- // Not relevant
710
- for (const [name] of Object.entries(notRelevant)) {
711
- rows.push({
712
- name,
713
- status: 'not_relevant',
714
- detail: 'not relevant to stack'
715
- });
716
- }
717
-
718
- return rows;
719
- }
1
+ /**
2
+ * MCP Installer
3
+ *
4
+ * Core logic for installing and configuring MCP servers during morph-spec init
5
+ * and via `morph-spec mcp setup`. Handles auto-installable (credential-free)
6
+ * MCPs, prerequisite checking, credential collection, and setup guidance.
7
+ */
8
+
9
+ import { join } from 'path';
10
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
+ import { execSync, spawn } from 'child_process';
12
+ import { homedir } from 'os';
13
+ import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
+
15
+ /**
16
+ * Load the enhanced MCP registry (v3.0.0)
17
+ * @returns {Object} Registry with install blocks
18
+ */
19
+ export function loadMcpRegistry() {
20
+ const registryPath = join(
21
+ import.meta.dirname, '..', '..', '..', 'framework', 'mcp', 'registry.json'
22
+ );
23
+ const raw = readFileSync(registryPath, 'utf-8');
24
+ return JSON.parse(raw);
25
+ }
26
+
27
+ /**
28
+ * Filter MCPs by stack relevance
29
+ * @param {Object} registry - Full MCP registry
30
+ * @param {string} stack - Detected project stack (e.g., 'nextjs-neon', 'blazor')
31
+ * @returns {Object} Filtered map of mcpName → mcpEntry
32
+ */
33
+ export function filterByStack(registry, stack) {
34
+ const result = {};
35
+ const normalizedStack = (stack || '').toLowerCase();
36
+
37
+ for (const [name, entry] of Object.entries(registry.mcps)) {
38
+ if (!entry.install) continue;
39
+ const relevance = entry.install.stackRelevance || [];
40
+
41
+ if (relevance.includes('*')) {
42
+ result[name] = entry;
43
+ } else if (normalizedStack && relevance.some(s => normalizedStack.includes(s))) {
44
+ result[name] = entry;
45
+ }
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * Match detected MCP servers against the registry
53
+ * @param {Array} existingServers - From detectClaudeConfig().mcpServers
54
+ * @param {Object} registry - MCP registry
55
+ * @returns {Object} Map of mcpName → { installed: true, source }
56
+ */
57
+ export function getInstalledMcps(existingServers, registry) {
58
+ const installed = {};
59
+ const registryNames = Object.keys(registry.mcps);
60
+
61
+ for (const server of existingServers) {
62
+ const serverName = server.name.toLowerCase();
63
+ for (const regName of registryNames) {
64
+ if (serverName.includes(regName)) {
65
+ installed[regName] = { installed: true, source: server.source };
66
+ }
67
+ }
68
+ }
69
+
70
+ return installed;
71
+ }
72
+
73
+ /**
74
+ * Check if prerequisites are met for an MCP entry
75
+ * @param {Object} mcpEntry - MCP entry from registry
76
+ * @returns {Array} Array of { name, met, installUrl } for each prerequisite
77
+ */
78
+ export function checkPrerequisites(mcpEntry) {
79
+ const prereqs = mcpEntry.install?.prerequisites || [];
80
+ const results = [];
81
+
82
+ for (const prereq of prereqs) {
83
+ let met = false;
84
+ try {
85
+ execSync(prereq.checkCommand, { stdio: 'ignore', timeout: 5000 });
86
+ met = true;
87
+ } catch {
88
+ // Command failed or not found
89
+ }
90
+ results.push({
91
+ name: prereq.name,
92
+ met,
93
+ installUrl: prereq.installUrl
94
+ });
95
+ }
96
+
97
+ return results;
98
+ }
99
+
100
+ /**
101
+ * Check if an MCP config uses remote HTTP transport (no local process).
102
+ * @param {Object} config - MCP server config
103
+ * @returns {boolean}
104
+ */
105
+ export function isRemoteMcp(config) {
106
+ return config.type === 'http' || config.url != null;
107
+ }
108
+
109
+ /**
110
+ * Patch MCP config for the current platform.
111
+ * On Windows, npx must be invoked via `cmd /c` to avoid ENOENT errors.
112
+ * Registry already stores cmd/c format; this handles legacy configs that still use npx directly.
113
+ * Remote HTTP MCPs are returned as-is (no local process to patch).
114
+ * @param {Object} config - MCP server config { command, args, env? } or { type: "http", url }
115
+ * @returns {Object} Patched config (new object, original unmodified)
116
+ */
117
+ export function patchConfigForPlatform(config) {
118
+ // Remote MCPs need no platform patching
119
+ if (isRemoteMcp(config)) return config;
120
+ if (config.command !== 'npx') return config;
121
+ return {
122
+ ...config,
123
+ command: 'cmd',
124
+ args: ['/c', 'npx', ...(config.args || [])]
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Install auto-installable MCPs (no credentials, no prerequisites)
130
+ * @param {string} targetPath - Project root directory
131
+ * @param {Object} mcpsToInstall - Map of mcpName → mcpEntry
132
+ * @returns {Promise<Object>} Result from installMcpServers
133
+ */
134
+ export async function installAutoMcps(targetPath, mcpsToInstall) {
135
+ const servers = {};
136
+
137
+ for (const [name, entry] of Object.entries(mcpsToInstall)) {
138
+ let config = { ...entry.install.config };
139
+ // Remote MCPs don't have env to strip
140
+ if (!isRemoteMcp(config)) {
141
+ // Only include env if it has actual values
142
+ if (config.env && Object.values(config.env).every(v => !v)) {
143
+ delete config.env;
144
+ }
145
+ }
146
+ servers[name] = patchConfigForPlatform(config);
147
+ }
148
+
149
+ return installMcpServers(targetPath, servers);
150
+ }
151
+
152
+ /**
153
+ * Install companion skills for MCPs that define them (e.g., Stitch agent skills).
154
+ * Reads the `companionSkills` field from registry entries and runs the install command.
155
+ * @param {Object} mcpsInstalled - Map of mcpName → mcpEntry that were just installed
156
+ * @param {Function} exec - execSync function (for testing)
157
+ * @returns {Array<{name: string, repo: string, success: boolean}>}
158
+ */
159
+ export function installCompanionSkills(mcpsInstalled, exec = execSync) {
160
+ const results = [];
161
+
162
+ for (const [name, entry] of Object.entries(mcpsInstalled)) {
163
+ const companion = entry.companionSkills;
164
+ if (!companion?.installCommand) continue;
165
+
166
+ try {
167
+ exec(companion.installCommand, { stdio: 'inherit', timeout: 30000 });
168
+ results.push({ name, repo: companion.repo, success: true });
169
+ } catch {
170
+ results.push({ name, repo: companion.repo, success: false });
171
+ }
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ /**
178
+ * Generate the Stitch OAuth proxy template script.
179
+ * Cross-platform (Windows gcloud.cmd + Unix gcloud), fetch()-based,
180
+ * with readline, session tracking, and graceful shutdown.
181
+ * @param {string} gcpProject - GCP project ID for x-goog-user-project header
182
+ * @returns {string} CJS proxy script content
183
+ */
184
+ export function getStitchProxyTemplate(gcpProject) {
185
+ return `#!/usr/bin/env node
186
+ /**
187
+ * Stitch MCP stdio-to-HTTP proxy with Google auth
188
+ * Generated by morph-spec mcp setup stitch
189
+ */
190
+ const { execSync } = require('child_process');
191
+ const { createInterface } = require('readline');
192
+
193
+ const STITCH_URL = 'https://stitch.googleapis.com/mcp';
194
+ const PROJECT_ID = '${gcpProject}';
195
+
196
+ // Cross-platform gcloud path: Windows uses gcloud.cmd from Cloud SDK install
197
+ const GCLOUD = process.platform === 'win32'
198
+ ? (process.env.LOCALAPPDATA || '') + '/Google/Cloud SDK/google-cloud-sdk/bin/gcloud.cmd'
199
+ : 'gcloud';
200
+
201
+ let cachedToken = null;
202
+ let tokenExpiry = 0;
203
+ let pendingRequests = 0;
204
+ let stdinClosed = false;
205
+ let sessionId = null;
206
+
207
+ function getToken() {
208
+ if (cachedToken && Date.now() < tokenExpiry) return cachedToken;
209
+ try {
210
+ cachedToken = execSync(\`"\${GCLOUD}" auth application-default print-access-token\`, {
211
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
212
+ }).trim();
213
+ tokenExpiry = Date.now() + 50 * 60 * 1000; // 50-minute TTL
214
+ return cachedToken;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function maybeExit() {
221
+ if (stdinClosed && pendingRequests === 0) process.exit(0);
222
+ }
223
+
224
+ async function handleMessage(line) {
225
+ let msg;
226
+ try { msg = JSON.parse(line); } catch { return; }
227
+
228
+ // Notifications (no id) don't need responses
229
+ if (msg.id === undefined && msg.method === 'notifications/initialized') return;
230
+
231
+ const token = getToken();
232
+ if (!token) {
233
+ if (msg.id !== undefined) {
234
+ process.stdout.write(JSON.stringify({
235
+ jsonrpc: '2.0', id: msg.id,
236
+ error: { code: -32000, message: 'Failed to get gcloud auth token. Run: gcloud auth application-default login' }
237
+ }) + '\\n');
238
+ }
239
+ return;
240
+ }
241
+
242
+ const headers = {
243
+ 'Content-Type': 'application/json',
244
+ 'Authorization': \`Bearer \${token}\`,
245
+ 'x-goog-user-project': PROJECT_ID,
246
+ };
247
+ if (sessionId) headers['mcp-session-id'] = sessionId;
248
+
249
+ pendingRequests++;
250
+ try {
251
+ const res = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
252
+
253
+ // Track sticky session
254
+ const sid = res.headers.get('mcp-session-id');
255
+ if (sid) sessionId = sid;
256
+
257
+ // Retry once on 401 (expired token)
258
+ if (res.status === 401) {
259
+ cachedToken = null;
260
+ tokenExpiry = 0;
261
+ const retryToken = getToken();
262
+ if (retryToken) {
263
+ headers['Authorization'] = \`Bearer \${retryToken}\`;
264
+ const retryRes = await fetch(STITCH_URL, { method: 'POST', headers, body: JSON.stringify(msg) });
265
+ const retryText = await retryRes.text();
266
+ if (retryText.trim()) {
267
+ process.stdout.write(retryText.endsWith('\\n') ? retryText : retryText + '\\n');
268
+ }
269
+ return;
270
+ }
271
+ }
272
+
273
+ const text = await res.text();
274
+ if (text.trim()) {
275
+ process.stdout.write(text.endsWith('\\n') ? text : text + '\\n');
276
+ }
277
+ } catch (err) {
278
+ if (msg.id !== undefined) {
279
+ process.stdout.write(JSON.stringify({
280
+ jsonrpc: '2.0', id: msg.id,
281
+ error: { code: -32000, message: \`Stitch API error: \${err.message}\` }
282
+ }) + '\\n');
283
+ }
284
+ } finally {
285
+ pendingRequests--;
286
+ maybeExit();
287
+ }
288
+ }
289
+
290
+ const rl = createInterface({ input: process.stdin });
291
+ rl.on('line', handleMessage);
292
+ rl.on('close', () => { stdinClosed = true; maybeExit(); });
293
+ `;
294
+ }
295
+
296
+ /**
297
+ * Generate and write the Stitch OAuth proxy script to the target project.
298
+ * @param {string} targetPath - Project root directory
299
+ * @param {string} gcpProject - GCP project ID
300
+ * @returns {{ proxyPath: string }} Path to the generated proxy script
301
+ */
302
+ export function generateStitchProxy(targetPath, gcpProject) {
303
+ const claudeDir = join(targetPath, '.claude');
304
+ if (!existsSync(claudeDir)) {
305
+ mkdirSync(claudeDir, { recursive: true });
306
+ }
307
+
308
+ const proxyPath = join(claudeDir, 'stitch-proxy.cjs');
309
+ writeFileSync(proxyPath, getStitchProxyTemplate(gcpProject), 'utf-8');
310
+
311
+ return { proxyPath };
312
+ }
313
+
314
+ /**
315
+ * Resolve the gcloud CLI binary path.
316
+ * Checks: system PATH → bundled at ~/.stitch-mcp/ → null.
317
+ * @param {Function} exec - execSync function (for testing)
318
+ * @returns {{ gcloudPath: string|null, source: string }}
319
+ */
320
+ export function findGcloud(exec = execSync) {
321
+ // Try system PATH first
322
+ try {
323
+ exec('gcloud --version', { stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 });
324
+ return { gcloudPath: 'gcloud', source: 'system' };
325
+ } catch { /* not on PATH */ }
326
+
327
+ // Windows: try Cloud SDK default install location
328
+ if (process.platform === 'win32') {
329
+ const winPath = join(process.env.LOCALAPPDATA || '', 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd');
330
+ if (existsSync(winPath)) {
331
+ return { gcloudPath: winPath, source: 'windows-sdk' };
332
+ }
333
+ }
334
+
335
+ // Try bundled at ~/.stitch-mcp/
336
+ const bundledDir = join(homedir(), '.stitch-mcp');
337
+ const bundledBin = process.platform === 'win32'
338
+ ? join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud.cmd')
339
+ : join(bundledDir, 'google-cloud-sdk', 'bin', 'gcloud');
340
+ if (existsSync(bundledBin)) {
341
+ return { gcloudPath: bundledBin, source: 'bundled' };
342
+ }
343
+
344
+ return { gcloudPath: null, source: 'not-found' };
345
+ }
346
+
347
+ /**
348
+ * Fully automated Stitch MCP setup orchestrator.
349
+ *
350
+ * Steps:
351
+ * 1. Find gcloud CLI
352
+ * 2. Verify ADC credentials
353
+ * 3. Detect GCP project
354
+ * 4. Enable stitch.googleapis.com API
355
+ * 5. Generate proxy script
356
+ * 6. Install MCP config in .mcp.json
357
+ * 7. Health-check the proxy
358
+ *
359
+ * @param {string} targetPath - Project root directory
360
+ * @param {Object} options
361
+ * @param {string} [options.gcpProject] - GCP project ID (auto-detected if omitted)
362
+ * @param {Function} [options._exec] - execSync override for testing
363
+ * @returns {Promise<Object>} Step-by-step results
364
+ */
365
+ export async function setupStitch(targetPath, options = {}) {
366
+ const exec = options._exec || execSync;
367
+ const steps = [];
368
+
369
+ // Step 1: Find gcloud
370
+ const { gcloudPath, source: gcloudSource } = findGcloud(exec);
371
+ if (!gcloudPath) {
372
+ steps.push({ step: 'find-gcloud', success: false, error: 'gcloud CLI not found. Install from: https://cloud.google.com/sdk/docs/install' });
373
+ return { success: false, steps };
374
+ }
375
+ steps.push({ step: 'find-gcloud', success: true, gcloudPath, source: gcloudSource });
376
+
377
+ const gcloudCmd = gcloudPath.includes(' ') ? `"${gcloudPath}"` : gcloudPath;
378
+
379
+ // Step 2: Verify ADC credentials
380
+ try {
381
+ exec(`${gcloudCmd} auth application-default print-access-token`, {
382
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000
383
+ });
384
+ steps.push({ step: 'verify-adc', success: true });
385
+ } catch {
386
+ steps.push({ step: 'verify-adc', success: false, error: 'No ADC credentials. Run: gcloud auth application-default login' });
387
+ return { success: false, steps };
388
+ }
389
+
390
+ // Step 3: Detect GCP project
391
+ let gcpProject = options.gcpProject;
392
+ if (!gcpProject) {
393
+ // Try config.json
394
+ const configPath = join(targetPath, '.morph', 'config', 'config.json');
395
+ if (existsSync(configPath)) {
396
+ try {
397
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
398
+ gcpProject = config.gcpProject || config.project?.gcpProject;
399
+ } catch { /* invalid config */ }
400
+ }
401
+ }
402
+ if (!gcpProject) {
403
+ // Try gcloud config
404
+ try {
405
+ gcpProject = exec(`${gcloudCmd} config get-value project`, {
406
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
407
+ }).trim();
408
+ if (!gcpProject || gcpProject === '(unset)') gcpProject = null;
409
+ } catch { /* gcloud config failed */ }
410
+ }
411
+ if (!gcpProject) {
412
+ steps.push({ step: 'detect-project', success: false, error: 'Could not detect GCP project. Pass it explicitly or set via: gcloud config set project <id>' });
413
+ return { success: false, steps };
414
+ }
415
+ steps.push({ step: 'detect-project', success: true, gcpProject });
416
+
417
+ // Step 4: Enable Stitch API
418
+ try {
419
+ exec(`${gcloudCmd} services enable stitch.googleapis.com --project=${gcpProject}`, {
420
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000
421
+ });
422
+ steps.push({ step: 'enable-api', success: true });
423
+ } catch (err) {
424
+ steps.push({ step: 'enable-api', success: false, error: `Failed to enable stitch API: ${err.message}` });
425
+ // Non-fatal — API might already be enabled and user may lack permissions to re-enable
426
+ }
427
+
428
+ // Step 5: Generate proxy
429
+ const { proxyPath } = generateStitchProxy(targetPath, gcpProject);
430
+ steps.push({ step: 'generate-proxy', success: true, proxyPath });
431
+
432
+ // Step 6: Install MCP config in .mcp.json
433
+ const mcpResult = await installMcpServers(targetPath, {
434
+ stitch: { type: 'stdio', command: 'node', args: ['.claude/stitch-proxy.cjs'] }
435
+ });
436
+ steps.push({ step: 'install-mcp', success: true, added: mcpResult.added, skipped: mcpResult.skipped });
437
+
438
+ // Step 7: Health-check
439
+ const healthResult = await healthCheckStitchProxy(proxyPath);
440
+ steps.push({ step: 'health-check', ...healthResult });
441
+
442
+ const allCriticalPassed = steps.every(s =>
443
+ s.success || s.step === 'enable-api' || s.step === 'health-check'
444
+ );
445
+
446
+ return { success: allCriticalPassed, steps, gcpProject };
447
+ }
448
+
449
+ /**
450
+ * Health-check the Stitch proxy by sending an MCP initialize handshake.
451
+ * @param {string} proxyPath - Path to stitch-proxy.cjs
452
+ * @returns {Promise<Object>} { success, protocolVersion?, error? }
453
+ */
454
+ export function healthCheckStitchProxy(proxyPath) {
455
+ return new Promise((resolve) => {
456
+ const initMsg = JSON.stringify({
457
+ jsonrpc: '2.0',
458
+ id: 1,
459
+ method: 'initialize',
460
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'morph-spec', version: '1.0.0' } }
461
+ });
462
+
463
+ const child = spawn('node', [proxyPath], {
464
+ stdio: ['pipe', 'pipe', 'pipe'],
465
+ timeout: 20000,
466
+ });
467
+
468
+ let output = '';
469
+ let timedOut = false;
470
+
471
+ const timer = setTimeout(() => {
472
+ timedOut = true;
473
+ child.kill();
474
+ resolve({ success: false, error: 'Health-check timed out after 20s' });
475
+ }, 20000);
476
+
477
+ child.stdout.on('data', (data) => {
478
+ output += data.toString();
479
+ // Check if we got a response
480
+ const lines = output.split('\n').filter(l => l.trim());
481
+ for (const line of lines) {
482
+ try {
483
+ const resp = JSON.parse(line);
484
+ if (resp.result?.protocolVersion) {
485
+ clearTimeout(timer);
486
+ child.kill();
487
+ resolve({ success: true, protocolVersion: resp.result.protocolVersion });
488
+ return;
489
+ }
490
+ if (resp.error) {
491
+ clearTimeout(timer);
492
+ child.kill();
493
+ resolve({ success: false, error: resp.error.message || 'MCP error' });
494
+ return;
495
+ }
496
+ } catch { /* not valid JSON yet */ }
497
+ }
498
+ });
499
+
500
+ child.on('error', (err) => {
501
+ if (!timedOut) {
502
+ clearTimeout(timer);
503
+ resolve({ success: false, error: err.message });
504
+ }
505
+ });
506
+
507
+ child.on('close', (code) => {
508
+ if (!timedOut) {
509
+ clearTimeout(timer);
510
+ resolve({ success: false, error: output.trim()
511
+ ? `Proxy exited with code ${code} — unexpected output`
512
+ : `Proxy exited with code ${code} and no output`
513
+ });
514
+ }
515
+ });
516
+
517
+ // Send the initialize message
518
+ child.stdin.write(initMsg + '\n');
519
+ child.stdin.end();
520
+ });
521
+ }
522
+
523
+ /**
524
+ * Install a single MCP with provided credentials
525
+ * @param {string} targetPath - Project root directory
526
+ * @param {string} name - MCP name
527
+ * @param {Object} mcpEntry - MCP entry from registry
528
+ * @param {Object} credentialValues - Map of envVar → value
529
+ * @returns {Promise<Object>} Result from installMcpServers
530
+ */
531
+ export async function installMcpWithCredentials(targetPath, name, mcpEntry, credentialValues) {
532
+ const config = patchConfigForPlatform({ ...mcpEntry.install.config });
533
+ config.env = { ...config.env, ...credentialValues };
534
+ return installMcpServers(targetPath, { [name]: config });
535
+ }
536
+
537
+ /**
538
+ * Generate setup instructions for a credential-requiring MCP
539
+ * @param {string} name - MCP name
540
+ * @param {Object} mcpEntry - MCP entry from registry
541
+ * @returns {Object} { configSnippet, credentialUrls, cliCommand }
542
+ */
543
+ export function generateSetupInstructions(name, mcpEntry) {
544
+ const config = mcpEntry.install.config;
545
+ const credentials = mcpEntry.install.credentials || [];
546
+ const setupGuide = mcpEntry.install.setupGuide;
547
+
548
+ // Remote HTTP MCPs — show claude mcp add command instead of config snippet
549
+ if (isRemoteMcp(config)) {
550
+ const configSnippet = JSON.stringify({ [name]: config }, null, 2);
551
+ const credentialUrls = credentials.map(c => ({
552
+ name: c.name,
553
+ envVar: c.envVar,
554
+ helpUrl: c.helpUrl
555
+ }));
556
+ return {
557
+ configSnippet,
558
+ credentialUrls,
559
+ cliCommand: setupGuide || `claude mcp add --transport http ${name} ${config.url}`,
560
+ isRemote: true
561
+ };
562
+ }
563
+
564
+ // Build the config snippet with placeholder values
565
+ const snippetConfig = { ...config };
566
+ if (credentials.length > 0) {
567
+ snippetConfig.env = {};
568
+ for (const cred of credentials) {
569
+ snippetConfig.env[cred.envVar] = cred.secret ? 'YOUR_KEY' : 'YOUR_VALUE';
570
+ }
571
+ }
572
+
573
+ const configSnippet = JSON.stringify({ [name]: snippetConfig }, null, 2);
574
+
575
+ const credentialUrls = credentials.map(c => ({
576
+ name: c.name,
577
+ envVar: c.envVar,
578
+ helpUrl: c.helpUrl
579
+ }));
580
+
581
+ return {
582
+ configSnippet,
583
+ credentialUrls,
584
+ cliCommand: `morph-spec mcp setup ${name}`
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Full orchestrator for MCP setup during init or `mcp setup`
590
+ * @param {string} targetPath - Project root directory
591
+ * @param {string} stack - Detected project stack
592
+ * @param {Array} existingMcps - From detectClaudeConfig().mcpServers
593
+ * @param {Object} options
594
+ * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
595
+ * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
596
+ */
597
+ export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
598
+ const registry = loadMcpRegistry();
599
+ const relevant = filterByStack(registry, stack);
600
+ const installed = getInstalledMcps(existingMcps || [], registry);
601
+
602
+ const autoInstallable = {};
603
+ const alreadyInstalled = {};
604
+ const needsManualSetup = {};
605
+ const notRelevant = {};
606
+ const prerequisitesMissing = {};
607
+
608
+ // Categorize all MCPs
609
+ for (const [name, entry] of Object.entries(registry.mcps)) {
610
+ if (!entry.install) continue;
611
+
612
+ // Check if already installed
613
+ if (installed[name]) {
614
+ alreadyInstalled[name] = { entry, source: installed[name].source };
615
+ continue;
616
+ }
617
+
618
+ // Check if relevant to stack
619
+ if (!relevant[name]) {
620
+ notRelevant[name] = entry;
621
+ continue;
622
+ }
623
+
624
+ // Check prerequisites
625
+ const prereqResults = checkPrerequisites(entry);
626
+ const unmetPrereqs = prereqResults.filter(p => !p.met);
627
+ if (unmetPrereqs.length > 0) {
628
+ prerequisitesMissing[name] = { entry, unmet: unmetPrereqs };
629
+ continue;
630
+ }
631
+
632
+ // Categorize by install type
633
+ if (entry.install.autoInstall) {
634
+ autoInstallable[name] = entry;
635
+ } else if (!options.autoOnly) {
636
+ needsManualSetup[name] = entry;
637
+ }
638
+ }
639
+
640
+ return {
641
+ registry,
642
+ autoInstallable,
643
+ alreadyInstalled,
644
+ needsManualSetup,
645
+ notRelevant,
646
+ prerequisitesMissing
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Format MCP status table for display
652
+ * @param {Object} orchestration - Result from orchestrateMcpSetup
653
+ * @param {Object} justInstalled - Map of names just installed during this run
654
+ * @returns {Array<Object>} Array of { name, status, detail }
655
+ */
656
+ export function formatMcpStatusTable(orchestration, justInstalled = {}) {
657
+ const rows = [];
658
+
659
+ const { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing, registry } = orchestration;
660
+
661
+ // Installed during this run
662
+ for (const [name, entry] of Object.entries(justInstalled)) {
663
+ const mcpEntry = registry.mcps[name];
664
+ rows.push({
665
+ name,
666
+ status: 'installed',
667
+ detail: mcpEntry ? mcpEntry.usage : ''
668
+ });
669
+ }
670
+
671
+ // Auto-installable but not yet installed (and not just-installed)
672
+ for (const [name, entry] of Object.entries(autoInstallable)) {
673
+ if (justInstalled[name]) continue;
674
+ rows.push({
675
+ name,
676
+ status: 'available',
677
+ detail: `Run: morph-spec mcp setup --auto`
678
+ });
679
+ }
680
+
681
+ // Already installed
682
+ for (const [name, { source }] of Object.entries(alreadyInstalled)) {
683
+ rows.push({
684
+ name,
685
+ status: 'already_configured',
686
+ detail: `(${source})`
687
+ });
688
+ }
689
+
690
+ // Needs manual setup
691
+ for (const [name, entry] of Object.entries(needsManualSetup)) {
692
+ rows.push({
693
+ name,
694
+ status: 'needs_setup',
695
+ detail: `Run: morph-spec mcp setup ${name}`
696
+ });
697
+ }
698
+
699
+ // Prerequisites missing
700
+ for (const [name, { unmet }] of Object.entries(prerequisitesMissing)) {
701
+ const missing = unmet.map(p => p.name).join(', ');
702
+ rows.push({
703
+ name,
704
+ status: 'prereq_missing',
705
+ detail: `Missing: ${missing}`
706
+ });
707
+ }
708
+
709
+ // Not relevant
710
+ for (const [name] of Object.entries(notRelevant)) {
711
+ rows.push({
712
+ name,
713
+ status: 'not_relevant',
714
+ detail: 'not relevant to stack'
715
+ });
716
+ }
717
+
718
+ return rows;
719
+ }