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