@polymorphism-tech/morph-spec 7.8.0 → 7.8.1

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 (166) hide show
  1. package/README.md +2 -2
  2. package/bin/validate.js +379 -379
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/commands/commit.md +166 -166
  7. package/framework/commands/morph-infra.md +215 -215
  8. package/framework/commands/morph-preflight.md +205 -205
  9. package/framework/commands/morph-troubleshoot.md +124 -124
  10. package/framework/hooks/claude-code/statusline.py +679 -679
  11. package/framework/hooks/claude-code/statusline.sh +11 -11
  12. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  13. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  14. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  15. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  16. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  17. package/framework/hooks/shared/activity-logger.js +160 -160
  18. package/framework/hooks/shared/hook-response.js +45 -45
  19. package/framework/hooks/shared/payload-utils.js +78 -78
  20. package/framework/hooks/shared/stdin-reader.js +26 -26
  21. package/framework/rubrics/backend.json +41 -41
  22. package/framework/rules/ai-agents-standards.md +45 -45
  23. package/framework/rules/dotnet-templates.md +86 -86
  24. package/framework/rules/frontend-standards.md +13 -13
  25. package/framework/rules/infrastructure-standards.md +27 -27
  26. package/framework/rules/neon-standards.md +25 -25
  27. package/framework/rules/nextjs-standards.md +20 -20
  28. package/framework/rules/nextjs-templates.md +40 -40
  29. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  30. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +8 -4
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-plan/SKILL.md +8 -4
  34. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  35. package/framework/skills/core/morph-review/SKILL.md +13 -7
  36. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  37. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  38. package/framework/standards/ai-agents/agent-session.md +160 -160
  39. package/framework/standards/ai-agents/batching.md +193 -193
  40. package/framework/standards/ai-agents/context-providers.md +188 -188
  41. package/framework/standards/ai-agents/mcp-server.md +250 -250
  42. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  43. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  44. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  45. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  46. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  47. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  48. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  49. package/framework/standards/ai-agents/production.md +275 -275
  50. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  51. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  52. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  53. package/framework/standards/ai-agents/structured-output.md +249 -249
  54. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  55. package/framework/templates/IDE-SNIPPETS.md +262 -262
  56. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  57. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  58. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  59. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  60. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  61. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  62. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  63. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  64. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  65. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  66. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  67. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  68. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  69. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  70. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  71. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  72. package/framework/templates/code/typescript/contracts.ts +167 -167
  73. package/framework/templates/docs/clarifications.md +253 -253
  74. package/framework/templates/docs/schema-analysis.md +119 -119
  75. package/framework/templates/docs/ui-components.md +52 -52
  76. package/framework/templates/docs/ui-design-system.md +76 -76
  77. package/framework/templates/docs/ui-flows.md +167 -167
  78. package/framework/templates/docs/ui-mockups.md +98 -98
  79. package/framework/templates/feature/decisions.md +122 -122
  80. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  81. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  82. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  83. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  84. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  85. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  86. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  87. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  88. package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
  89. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  90. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  91. package/framework/templates/infrastructure/github/README.md +56 -56
  92. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  93. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  94. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  95. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  96. package/framework/templates/integrations/asaas-client.cs +387 -387
  97. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  98. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  99. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  100. package/framework/templates/meta-prompts/parallel-workers/parallel-coordinator.md +113 -113
  101. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  102. package/framework/templates/project/validate.js +120 -120
  103. package/framework/templates/state.template.json +17 -17
  104. package/framework/templates/ui/design-system.css +226 -226
  105. package/package.json +1 -1
  106. package/src/commands/dashboard/dashboard.js +53 -53
  107. package/src/commands/index.js +13 -13
  108. package/src/commands/mcp/mcp-setup.js +519 -519
  109. package/src/commands/project/checkpoint.js +209 -209
  110. package/src/commands/project/update.js +7 -4
  111. package/src/core/state/phase-state-machine.js +197 -197
  112. package/src/core/templates/template-renderer.js +477 -477
  113. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  114. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  115. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  116. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  117. package/src/dashboard/app/main.jsx +13 -13
  118. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  119. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  120. package/src/dashboard/app/pages/LiveState.jsx +239 -239
  121. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  122. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  123. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  124. package/src/dashboard/app/pages/Standards.jsx +332 -332
  125. package/src/dashboard/app/styles/index.css +47 -47
  126. package/src/dashboard/index.html +12 -12
  127. package/src/dashboard/package.json +24 -24
  128. package/src/dashboard/scanner/hooks-scanner.js +41 -41
  129. package/src/dashboard/scanner/phases-scanner.js +15 -15
  130. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  131. package/src/dashboard/scanner/rules-scanner.js +37 -37
  132. package/src/dashboard/scanner/skills-scanner.js +43 -43
  133. package/src/dashboard/scanner/squads-scanner.js +13 -13
  134. package/src/dashboard/scanner/standards-scanner.js +26 -26
  135. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  136. package/src/dashboard/vite.config.js +24 -24
  137. package/src/lib/agents/metrics-aggregator.js +56 -56
  138. package/src/lib/agents/metrics-calculator.js +125 -125
  139. package/src/lib/agents/output-collector.js +73 -73
  140. package/src/lib/detectors/claude-config-detector.js +152 -152
  141. package/src/lib/detectors/design-system-detector.js +231 -231
  142. package/src/lib/detectors/index.js +217 -217
  143. package/src/lib/generators/architecture-scanner.js +639 -639
  144. package/src/lib/generators/design-system-generator.js +508 -508
  145. package/src/lib/generators/index.js +13 -13
  146. package/src/lib/generators/settings-generator.js +209 -209
  147. package/src/lib/hooks/hook-executor.js +167 -167
  148. package/src/lib/installers/mcp-installer.js +719 -719
  149. package/src/lib/tasks/task-classifier.js +30 -30
  150. package/src/lib/tasks/task-parser.js +209 -209
  151. package/src/lib/tasks/test-runner.js +81 -81
  152. package/src/lib/validators/architecture/architecture-validator.js +259 -259
  153. package/src/lib/validators/checkpoint-persistence.js +39 -39
  154. package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
  155. package/src/lib/validators/css/css-validator.js +340 -340
  156. package/src/lib/validators/packages/package-validator.js +347 -347
  157. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  158. package/src/lib/validators/shared/issue-counter.js +14 -14
  159. package/src/lib/validators/validation-runner.js +349 -349
  160. package/src/scripts/setup-infra.js +383 -383
  161. package/src/utils/agent-id-mapper.js +11 -11
  162. package/src/utils/banner.js +1 -2
  163. package/src/utils/claude-settings-manager.js +300 -300
  164. package/src/utils/skills-installer.js +172 -172
  165. package/framework/templates/.idea/morph-templates.xml +0 -92
  166. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -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
+ }