swallowkit 2.0.0-alpha.3 → 2.0.0-alpha.5

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 (159) hide show
  1. package/dist/cli/commands/add-auth.js +37 -6
  2. package/dist/cli/commands/add-auth.js.map +1 -1
  3. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  4. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  5. package/dist/cli/commands/configure-mcp.js +42 -0
  6. package/dist/cli/commands/configure-mcp.js.map +1 -0
  7. package/dist/cli/commands/init.d.ts +6 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +19 -5
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/migrations.d.ts.map +1 -1
  12. package/dist/cli/commands/migrations.js +25 -0
  13. package/dist/cli/commands/migrations.js.map +1 -1
  14. package/dist/cli/commands/provision.d.ts +2 -0
  15. package/dist/cli/commands/provision.d.ts.map +1 -1
  16. package/dist/cli/commands/provision.js +45 -16
  17. package/dist/cli/commands/provision.js.map +1 -1
  18. package/dist/cli/commands/scaffold.d.ts +4 -0
  19. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  20. package/dist/cli/commands/scaffold.js +85 -3
  21. package/dist/cli/commands/scaffold.js.map +1 -1
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +29 -4
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/core/operations/create-model.d.ts +2 -0
  27. package/dist/core/operations/create-model.d.ts.map +1 -1
  28. package/dist/core/operations/create-model.js +60 -8
  29. package/dist/core/operations/create-model.js.map +1 -1
  30. package/dist/core/operations/model-migration.d.ts +22 -0
  31. package/dist/core/operations/model-migration.d.ts.map +1 -0
  32. package/dist/core/operations/model-migration.js +36 -0
  33. package/dist/core/operations/model-migration.js.map +1 -0
  34. package/dist/core/operations/provision-operations.d.ts +19 -1
  35. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  36. package/dist/core/operations/provision-operations.js +62 -35
  37. package/dist/core/operations/provision-operations.js.map +1 -1
  38. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  39. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  40. package/dist/core/operations/scaffold-apply.js +1 -0
  41. package/dist/core/operations/scaffold-apply.js.map +1 -1
  42. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  43. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  44. package/dist/core/operations/scaffold-machine.js.map +1 -1
  45. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  46. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  47. package/dist/core/operations/scaffold-plan.js +3 -1
  48. package/dist/core/operations/scaffold-plan.js.map +1 -1
  49. package/dist/core/project/boundaries.d.ts +5 -2
  50. package/dist/core/project/boundaries.d.ts.map +1 -1
  51. package/dist/core/project/boundaries.js +51 -20
  52. package/dist/core/project/boundaries.js.map +1 -1
  53. package/dist/core/project/capabilities.d.ts +2 -0
  54. package/dist/core/project/capabilities.d.ts.map +1 -1
  55. package/dist/core/project/capabilities.js +29 -5
  56. package/dist/core/project/capabilities.js.map +1 -1
  57. package/dist/core/project/mcp-config.d.ts +26 -0
  58. package/dist/core/project/mcp-config.d.ts.map +1 -0
  59. package/dist/core/project/mcp-config.js +117 -0
  60. package/dist/core/project/mcp-config.js.map +1 -0
  61. package/dist/core/project/react-project.js +1 -1
  62. package/dist/core/project/workflows.d.ts.map +1 -1
  63. package/dist/core/project/workflows.js +23 -5
  64. package/dist/core/project/workflows.js.map +1 -1
  65. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  66. package/dist/core/scaffold/auth-generator.js +18 -19
  67. package/dist/core/scaffold/auth-generator.js.map +1 -1
  68. package/dist/core/scaffold/model-parser.d.ts +23 -0
  69. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  70. package/dist/core/scaffold/model-parser.js +407 -137
  71. package/dist/core/scaffold/model-parser.js.map +1 -1
  72. package/dist/core/scaffold/native-schema-generator.d.ts +13 -0
  73. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  74. package/dist/core/scaffold/native-schema-generator.js +289 -76
  75. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  76. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  77. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  78. package/dist/core/scaffold/openapi-generator.js +53 -3
  79. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  80. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  81. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  82. package/dist/core/scaffold/ui-generator.js +68 -7
  83. package/dist/core/scaffold/ui-generator.js.map +1 -1
  84. package/dist/core/verify/index.d.ts +4 -3
  85. package/dist/core/verify/index.d.ts.map +1 -1
  86. package/dist/core/verify/index.js +122 -5
  87. package/dist/core/verify/index.js.map +1 -1
  88. package/dist/machine/contracts.d.ts +1 -0
  89. package/dist/machine/contracts.d.ts.map +1 -1
  90. package/dist/machine/errors.d.ts +3 -2
  91. package/dist/machine/errors.d.ts.map +1 -1
  92. package/dist/machine/errors.js +2 -1
  93. package/dist/machine/errors.js.map +1 -1
  94. package/dist/machine/index.d.ts.map +1 -1
  95. package/dist/machine/index.js +42 -17
  96. package/dist/machine/index.js.map +1 -1
  97. package/dist/mcp/index.d.ts +4 -2
  98. package/dist/mcp/index.d.ts.map +1 -1
  99. package/dist/mcp/index.js +154 -15
  100. package/dist/mcp/index.js.map +1 -1
  101. package/dist/mcp/version-selection.d.ts +55 -0
  102. package/dist/mcp/version-selection.d.ts.map +1 -0
  103. package/dist/mcp/version-selection.js +152 -0
  104. package/dist/mcp/version-selection.js.map +1 -0
  105. package/dist/utils/azure-cli.d.ts +44 -0
  106. package/dist/utils/azure-cli.d.ts.map +1 -0
  107. package/dist/utils/azure-cli.js +164 -0
  108. package/dist/utils/azure-cli.js.map +1 -0
  109. package/dist/utils/semver.d.ts +14 -0
  110. package/dist/utils/semver.d.ts.map +1 -0
  111. package/dist/utils/semver.js +61 -0
  112. package/dist/utils/semver.js.map +1 -0
  113. package/package.json +6 -3
  114. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  115. package/src/__tests__/agent-loop-phase34.test.ts +124 -0
  116. package/src/__tests__/azure-cli.test.ts +199 -0
  117. package/src/__tests__/boundaries.test.ts +42 -0
  118. package/src/__tests__/csharp-schema.test.ts +261 -0
  119. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  120. package/src/__tests__/mcp-version.test.ts +262 -0
  121. package/src/__tests__/mcp.test.ts +64 -3
  122. package/src/__tests__/multi-auth.test.ts +115 -4
  123. package/src/__tests__/plan-apply.test.ts +170 -0
  124. package/src/__tests__/schema-analysis.test.ts +329 -0
  125. package/src/cli/commands/add-auth.ts +36 -6
  126. package/src/cli/commands/configure-mcp.ts +54 -0
  127. package/src/cli/commands/init.ts +23 -8
  128. package/src/cli/commands/migrations.ts +22 -0
  129. package/src/cli/commands/provision.ts +53 -17
  130. package/src/cli/commands/scaffold.ts +105 -3
  131. package/src/cli/index.ts +28 -5
  132. package/src/core/operations/create-model.ts +51 -13
  133. package/src/core/operations/model-migration.ts +52 -0
  134. package/src/core/operations/provision-operations.ts +86 -42
  135. package/src/core/operations/scaffold-apply.ts +2 -0
  136. package/src/core/operations/scaffold-machine.ts +1 -0
  137. package/src/core/operations/scaffold-plan.ts +5 -1
  138. package/src/core/project/agent-templates/AGENTS.md +67 -6
  139. package/src/core/project/agent-templates/CLAUDE.md +7 -1
  140. package/src/core/project/agent-templates/copilot-instructions.md +7 -1
  141. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  142. package/src/core/project/boundaries.ts +62 -20
  143. package/src/core/project/capabilities.ts +32 -5
  144. package/src/core/project/mcp-config.ts +84 -0
  145. package/src/core/project/react-project.ts +1 -1
  146. package/src/core/project/workflows.ts +23 -5
  147. package/src/core/scaffold/auth-generator.ts +18 -19
  148. package/src/core/scaffold/model-parser.ts +413 -129
  149. package/src/core/scaffold/native-schema-generator.ts +313 -78
  150. package/src/core/scaffold/openapi-generator.ts +63 -5
  151. package/src/core/scaffold/ui-generator.ts +70 -8
  152. package/src/core/verify/index.ts +118 -6
  153. package/src/machine/contracts.ts +1 -0
  154. package/src/machine/errors.ts +4 -2
  155. package/src/machine/index.ts +45 -20
  156. package/src/mcp/index.ts +173 -18
  157. package/src/mcp/version-selection.ts +163 -0
  158. package/src/utils/azure-cli.ts +154 -0
  159. package/src/utils/semver.ts +59 -0
package/src/mcp/index.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawn } from "child_process";
3
4
  import * as path from "path";
4
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
7
  import * as z from "zod/v4";
7
- import type { MachineNextAction, MachineOperationStatus, MachineResponse } from "../machine/contracts";
8
+ import type { MachineErrorResponse, MachineNextAction, MachineOperationStatus, MachineResponse } from "../machine/contracts";
8
9
  import { getSwallowKitVersion } from "../version";
10
+ import { detectFromProject, getCommands } from "../utils/package-manager";
11
+ import {
12
+ MCP_DELEGATED_FROM_ENV,
13
+ McpVersionContext,
14
+ buildPinnedUpdateNotice,
15
+ decideMcpLaunch,
16
+ resolveProjectSwallowKit,
17
+ } from "./version-selection";
9
18
 
10
19
  interface MachineSuccessPayload<TData> {
11
20
  ok: true;
@@ -16,7 +25,12 @@ interface MachineSuccessPayload<TData> {
16
25
  }
17
26
 
18
27
  type MachineCliRunner = (args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
19
- type ToolContentResult = { content: Array<{ type: "text"; text: string }> };
28
+ type ToolContentResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean };
29
+ class MachineCliError extends Error {
30
+ constructor(message: string, readonly response: MachineErrorResponse) {
31
+ super(message);
32
+ }
33
+ }
20
34
  type ToolDefinition = {
21
35
  name: string;
22
36
  description: string;
@@ -64,12 +78,35 @@ async function executeMachineCommand<TData>(
64
78
  const detailsSuffix =
65
79
  parsed.error.details !== undefined ? `\ndetails: ${JSON.stringify(parsed.error.details)}` : "";
66
80
  const statusSuffix = parsed.status ? ` (status: ${parsed.status})` : "";
67
- throw new Error(`[${parsed.error.code}] ${parsed.error.message}${statusSuffix}${detailsSuffix}`);
81
+ throw new MachineCliError(`[${parsed.error.code}] ${parsed.error.message}${statusSuffix}${detailsSuffix}`, parsed);
68
82
  }
69
83
 
70
84
  return parsed;
71
85
  }
72
86
 
87
+ function defaultVersionContext(): McpVersionContext {
88
+ return {
89
+ mcpVersion: getSwallowKitVersion(),
90
+ requestedVersion: "latest",
91
+ projectVersion: null,
92
+ selection: "running-version",
93
+ delegated: false,
94
+ warnings: [],
95
+ nextActions: [],
96
+ };
97
+ }
98
+
99
+ function mergeNextActions(
100
+ first: MachineNextAction[] | undefined,
101
+ second: MachineNextAction[]
102
+ ): MachineNextAction[] {
103
+ const merged = [...(first ?? [])];
104
+ for (const action of second) {
105
+ if (!merged.some((existing) => existing.command === action.command)) merged.push(action);
106
+ }
107
+ return merged;
108
+ }
109
+
73
110
  function withOperationStatus<TData>(response: MachineSuccessPayload<TData>): Record<string, unknown> {
74
111
  const data = response.data && typeof response.data === "object"
75
112
  ? (response.data as Record<string, unknown>)
@@ -92,25 +129,41 @@ function jsonTextContent(value: unknown): { content: Array<{ type: "text"; text:
92
129
  };
93
130
  }
94
131
 
95
- function withSwallowKitMetadata<TData>(data: TData): TData & { metadata: Record<string, unknown> } {
96
- const value = data && typeof data === "object" ? data as Record<string, unknown> : { data };
132
+ function withVersionMetadata(data: unknown, context: McpVersionContext): Record<string, unknown> {
133
+ const value = data && typeof data === "object" && !Array.isArray(data)
134
+ ? data as Record<string, unknown>
135
+ : { data };
97
136
  const metadata = value.metadata && typeof value.metadata === "object"
98
137
  ? value.metadata as Record<string, unknown>
99
138
  : {};
139
+ const existingNextActions = Array.isArray(value.nextActions) ? value.nextActions as MachineNextAction[] : undefined;
140
+ const nextActions = mergeNextActions(existingNextActions, context.nextActions);
100
141
 
101
142
  return {
102
143
  ...value,
144
+ ...(nextActions.length > 0 ? { nextActions } : {}),
103
145
  metadata: {
104
146
  ...metadata,
105
- swallowkitVersion: getSwallowKitVersion(),
147
+ swallowkitVersion: context.mcpVersion,
148
+ mcp: {
149
+ version: context.mcpVersion,
150
+ requestedVersion: context.requestedVersion,
151
+ projectVersion: context.projectVersion,
152
+ selection: context.selection,
153
+ delegated: context.delegated,
154
+ ...(context.delegatedFrom ? { delegatedFrom: context.delegatedFrom } : {}),
155
+ },
156
+ ...(context.warnings.length > 0 ? { warnings: [...context.warnings] } : {}),
106
157
  },
107
- } as TData & { metadata: Record<string, unknown> };
158
+ };
108
159
  }
109
160
 
110
161
  export function buildSwallowKitToolDefinitions(
111
- runMachineCli: MachineCliRunner = defaultMachineCliRunner
162
+ runMachineCli: MachineCliRunner = defaultMachineCliRunner,
163
+ versionContext: McpVersionContext = defaultVersionContext()
112
164
  ): ToolDefinition[] {
113
- return [
165
+ const withSwallowKitMetadata = (data: unknown) => withVersionMetadata(data, versionContext);
166
+ const definitions: ToolDefinition[] = [
114
167
  {
115
168
  name: "swallowkit_inspect_project",
116
169
  description: "Return framework-owned SwallowKit project metadata.",
@@ -169,8 +222,9 @@ export function buildSwallowKitToolDefinitions(
169
222
  functionsDir: z.string().optional(),
170
223
  apiDir: z.string().optional(),
171
224
  apiOnly: z.boolean().optional(),
225
+ skipInfra: z.boolean().optional().describe("Do not create or update anything under infra/; the container migration stays pending."),
172
226
  }),
173
- handler: async ({ model, functionsDir, apiDir, apiOnly }: { model: string; functionsDir?: string; apiDir?: string; apiOnly?: boolean }) => {
227
+ handler: async ({ model, functionsDir, apiDir, apiOnly, skipInfra }: { model: string; functionsDir?: string; apiDir?: string; apiOnly?: boolean; skipInfra?: boolean }) => {
174
228
  const args = ["generate", "scaffold", model];
175
229
  if (functionsDir) {
176
230
  args.push("--functions-dir", functionsDir);
@@ -181,6 +235,9 @@ export function buildSwallowKitToolDefinitions(
181
235
  if (apiOnly) {
182
236
  args.push("--api-only");
183
237
  }
238
+ if (skipInfra) {
239
+ args.push("--skip-infra");
240
+ }
184
241
 
185
242
  const response = await executeMachineCommand(args, runMachineCli);
186
243
  return jsonTextContent(response.data);
@@ -195,14 +252,16 @@ export function buildSwallowKitToolDefinitions(
195
252
  functionsDir: z.string().optional(),
196
253
  apiDir: z.string().optional(),
197
254
  apiOnly: z.boolean().optional(),
255
+ skipInfra: z.boolean().optional().describe("Exclude all infra/ changes (container migration) from the plan."),
198
256
  }),
199
- handler: async ({ model, models, functionsDir, apiDir, apiOnly }: { model?: string; models?: string[]; functionsDir?: string; apiDir?: string; apiOnly?: boolean }) => {
257
+ handler: async ({ model, models, functionsDir, apiDir, apiOnly, skipInfra }: { model?: string; models?: string[]; functionsDir?: string; apiDir?: string; apiOnly?: boolean; skipInfra?: boolean }) => {
200
258
  const targets = models && models.length > 0 ? models : model ? [model] : [];
201
259
  if (targets.length === 0) throw new Error("Provide model or models.");
202
260
  const args = ["plan", "scaffold", ...targets];
203
261
  if (functionsDir) args.push("--functions-dir", functionsDir);
204
262
  if (apiDir) args.push("--api-dir", apiDir);
205
263
  if (apiOnly) args.push("--api-only");
264
+ if (skipInfra) args.push("--skip-infra");
206
265
 
207
266
  const response = await executeMachineCommand(args, runMachineCli);
208
267
  return jsonTextContent(withOperationStatus(response));
@@ -218,8 +277,9 @@ export function buildSwallowKitToolDefinitions(
218
277
  functionsDir: z.string().optional(),
219
278
  apiDir: z.string().optional(),
220
279
  apiOnly: z.boolean().optional(),
280
+ skipInfra: z.boolean().optional().describe("Do not create or update anything under infra/ (defaults to the plan's setting)."),
221
281
  }),
222
- handler: async ({ model, planId, approve, functionsDir, apiDir, apiOnly }: { model?: string; planId?: string; approve?: boolean; functionsDir?: string; apiDir?: string; apiOnly?: boolean }) => {
282
+ handler: async ({ model, planId, approve, functionsDir, apiDir, apiOnly, skipInfra }: { model?: string; planId?: string; approve?: boolean; functionsDir?: string; apiDir?: string; apiOnly?: boolean; skipInfra?: boolean }) => {
223
283
  const args = ["apply", "scaffold"];
224
284
  if (model) args.push(model);
225
285
  if (planId) args.push("--plan", planId);
@@ -227,6 +287,7 @@ export function buildSwallowKitToolDefinitions(
227
287
  if (functionsDir) args.push("--functions-dir", functionsDir);
228
288
  if (apiDir) args.push("--api-dir", apiDir);
229
289
  if (apiOnly) args.push("--api-only");
290
+ if (skipInfra) args.push("--skip-infra");
230
291
 
231
292
  const response = await executeMachineCommand(args, runMachineCli);
232
293
  return jsonTextContent(withOperationStatus(response));
@@ -252,7 +313,7 @@ export function buildSwallowKitToolDefinitions(
252
313
  },
253
314
  {
254
315
  name: "swallowkit_verify_project",
255
- description: "Run verification checks (structure, drift, typecheck by default; build, lint, test and custom checks from swallowkit.config verify.checks are also available) and return machine-readable evidence. summary.done=true means the project passed all checks. Set compact=true to suppress info-severity findings and keep the output small.",
316
+ description: "Run verification checks (structure, drift, typecheck, backend-build by default; build, lint, test and custom checks from swallowkit.config verify.checks are also available) and return machine-readable evidence. backend-build compiles standalone C#/TypeScript Functions or checks Python syntax; unavailable toolchains are skipped with a reason. summary.done=true means no checks failed, not that skipped checks were validated or runtime/auth flows work. Set compact=true to suppress info-severity findings and keep the output small.",
256
317
  inputSchema: z.object({
257
318
  checks: z.array(z.string()).optional(),
258
319
  compact: z.boolean().optional(),
@@ -391,16 +452,36 @@ export function buildSwallowKitToolDefinitions(
391
452
  },
392
453
  },
393
454
  ];
455
+
456
+ // Every response reports which SwallowKit version answered and why it was selected.
457
+ return definitions.map((definition) => ({
458
+ ...definition,
459
+ handler: async (input: unknown) => {
460
+ try {
461
+ const result = await definition.handler(input);
462
+ return jsonTextContent(withSwallowKitMetadata(JSON.parse(result.content[0].text)));
463
+ } catch (error) {
464
+ // Prerequisite failures carry nextActions; keep them structured so agents can follow them.
465
+ if (error instanceof MachineCliError && error.response.nextActions?.length) {
466
+ return { ...jsonTextContent(withSwallowKitMetadata(error.response)), isError: true };
467
+ }
468
+ throw error;
469
+ }
470
+ },
471
+ }));
394
472
  }
395
473
 
396
- export function createSwallowKitMcpServer(runMachineCli: MachineCliRunner = defaultMachineCliRunner): McpServer {
397
- const version = getSwallowKitVersion();
474
+ export function createSwallowKitMcpServer(
475
+ runMachineCli: MachineCliRunner = defaultMachineCliRunner,
476
+ versionContext: McpVersionContext = defaultVersionContext()
477
+ ): McpServer {
478
+ const version = versionContext.mcpVersion;
398
479
  const server = new McpServer({
399
480
  name: "swallowkit-mcp",
400
481
  version,
401
482
  });
402
483
 
403
- for (const tool of buildSwallowKitToolDefinitions(runMachineCli)) {
484
+ for (const tool of buildSwallowKitToolDefinitions(runMachineCli, versionContext)) {
404
485
  server.registerTool(
405
486
  tool.name,
406
487
  {
@@ -414,9 +495,83 @@ export function createSwallowKitMcpServer(runMachineCli: MachineCliRunner = defa
414
495
  return server;
415
496
  }
416
497
 
498
+ function resolveExecCommand(cwd: string): string {
499
+ try {
500
+ return getCommands(detectFromProject(cwd)).exec;
501
+ } catch {
502
+ return "npx";
503
+ }
504
+ }
505
+
506
+ async function fetchPublishedLatest(): Promise<string | null> {
507
+ if (process.env.SWALLOWKIT_MCP_UPDATE_CHECK === "0") return null;
508
+ const registry = (process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
509
+ try {
510
+ const response = await fetch(`${registry}/swallowkit/latest`, { signal: AbortSignal.timeout(3000) });
511
+ if (!response.ok) return null;
512
+ const body = await response.json() as { version?: unknown };
513
+ return typeof body.version === "string" ? body.version : null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ function delegateToProjectMcp(entrypoint: string, fromVersion: string): void {
520
+ const child = spawn(process.execPath, [entrypoint], {
521
+ stdio: "inherit",
522
+ env: { ...process.env, [MCP_DELEGATED_FROM_ENV]: fromVersion },
523
+ });
524
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
525
+ process.on(signal, () => child.kill(signal));
526
+ }
527
+ child.on("error", (error) => {
528
+ console.error(`[swallowkit-mcp] failed to start the project-installed MCP: ${error.message}`);
529
+ process.exitCode = 1;
530
+ });
531
+ child.on("exit", (code, signal) => {
532
+ process.exitCode = code ?? (signal ? 1 : 0);
533
+ });
534
+ }
535
+
417
536
  export async function runMcpServer(): Promise<void> {
418
- console.error(`[swallowkit-mcp] version: ${getSwallowKitVersion()}`);
419
- const server = createSwallowKitMcpServer();
537
+ const selfVersion = getSwallowKitVersion();
538
+ const cwd = process.cwd();
539
+ const execCommand = resolveExecCommand(cwd);
540
+ const decision = decideMcpLaunch({
541
+ selfVersion,
542
+ selfPackageRoot: path.resolve(__dirname, "..", ".."),
543
+ env: process.env,
544
+ project: resolveProjectSwallowKit(cwd),
545
+ execCommand,
546
+ });
547
+
548
+ if (decision.action === "delegate") {
549
+ console.error(
550
+ `[swallowkit-mcp] version: ${selfVersion}; delegating to the project-installed swallowkit-mcp ${decision.toVersion} (newer than the launcher).`
551
+ );
552
+ delegateToProjectMcp(decision.entrypoint, decision.fromVersion);
553
+ return;
554
+ }
555
+
556
+ const context = decision.context;
557
+ // One write keeps the version line and its warnings together for log readers.
558
+ console.error([
559
+ `[swallowkit-mcp] version: ${selfVersion} (selection: ${context.selection}${context.delegatedFrom ? `, delegated from ${context.delegatedFrom}` : ""})`,
560
+ ...context.warnings.map((warning) => `[swallowkit-mcp] warning: ${warning}`),
561
+ ].join("\n"));
562
+
563
+ if (context.selection === "pinned") {
564
+ void fetchPublishedLatest().then((latest) => {
565
+ const notice = buildPinnedUpdateNotice(context, latest, execCommand);
566
+ if (notice) {
567
+ context.warnings.push(notice.warning);
568
+ context.nextActions.push(notice.nextAction);
569
+ console.error(`[swallowkit-mcp] warning: ${notice.warning}`);
570
+ }
571
+ });
572
+ }
573
+
574
+ const server = createSwallowKitMcpServer(defaultMachineCliRunner, context);
420
575
  const transport = new StdioServerTransport();
421
576
  await server.connect(transport);
422
577
  }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Runtime version selection for `swallowkit-mcp`.
3
+ *
4
+ * Policy: when launched as `latest` (or without an explicit version), use whichever is
5
+ * newer of this MCP package and the project-installed `swallowkit`. A newer project
6
+ * install takes over through its own `swallowkit-mcp`, so prerelease projects never get
7
+ * an older contract. An explicit SWALLOWKIT_MCP_VERSION is always honored.
8
+ */
9
+
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import type { MachineNextAction } from "../machine/contracts";
13
+ import { MCP_LATEST, MCP_VERSION_ENV } from "../core/project/mcp-config";
14
+ import { compareSemver } from "../utils/semver";
15
+
16
+ export const MCP_DELEGATED_FROM_ENV = "SWALLOWKIT_MCP_DELEGATED_FROM";
17
+
18
+ export interface ProjectSwallowKitInstall {
19
+ version: string;
20
+ packageRoot: string;
21
+ mcpEntrypoint: string | null;
22
+ }
23
+
24
+ export type McpVersionSelection =
25
+ | "running-version"
26
+ | "project-version"
27
+ | "pinned";
28
+
29
+ export interface McpVersionContext {
30
+ /** Version of the MCP server answering the request. */
31
+ mcpVersion: string;
32
+ /** SWALLOWKIT_MCP_VERSION as requested by the launcher (`latest` when unset). */
33
+ requestedVersion: string;
34
+ projectVersion: string | null;
35
+ selection: McpVersionSelection;
36
+ delegated: boolean;
37
+ delegatedFrom?: string;
38
+ warnings: string[];
39
+ nextActions: MachineNextAction[];
40
+ }
41
+
42
+ export type McpLaunchDecision =
43
+ | { action: "delegate"; entrypoint: string; fromVersion: string; toVersion: string }
44
+ | { action: "serve"; context: McpVersionContext };
45
+
46
+ export interface McpLaunchInput {
47
+ selfVersion: string;
48
+ selfPackageRoot: string;
49
+ env: NodeJS.ProcessEnv;
50
+ project: ProjectSwallowKitInstall | null;
51
+ execCommand?: string;
52
+ }
53
+
54
+ function normalizeRoot(value: string): string {
55
+ const resolved = path.resolve(value);
56
+ try {
57
+ return fs.realpathSync.native(resolved).toLowerCase();
58
+ } catch {
59
+ return resolved.toLowerCase();
60
+ }
61
+ }
62
+
63
+ export function resolveProjectSwallowKit(cwd: string): ProjectSwallowKitInstall | null {
64
+ // Same lookup order as Node's node_modules resolution from the project directory.
65
+ let packageJsonPath: string | null = null;
66
+ for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) {
67
+ const candidate = path.join(directory, "node_modules", "swallowkit", "package.json");
68
+ if (fs.existsSync(candidate)) {
69
+ packageJsonPath = fs.realpathSync(candidate);
70
+ break;
71
+ }
72
+ if (path.dirname(directory) === directory) return null;
73
+ }
74
+
75
+ try {
76
+ const manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
77
+ version?: string;
78
+ bin?: string | Record<string, string>;
79
+ };
80
+ if (!manifest.version) return null;
81
+ const packageRoot = path.dirname(packageJsonPath);
82
+ const bin = typeof manifest.bin === "object" ? manifest.bin["swallowkit-mcp"] : undefined;
83
+ const candidate = path.resolve(packageRoot, bin ?? path.join("dist", "mcp", "index.js"));
84
+ return {
85
+ version: manifest.version,
86
+ packageRoot,
87
+ mcpEntrypoint: fs.existsSync(candidate) ? candidate : null,
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function decideMcpLaunch(input: McpLaunchInput): McpLaunchDecision {
95
+ const { selfVersion, env, project } = input;
96
+ const exec = input.execCommand ?? "npx";
97
+ const requested = (env[MCP_VERSION_ENV] ?? "").trim() || MCP_LATEST;
98
+ const pinned = requested !== MCP_LATEST;
99
+ const delegatedFrom = (env[MCP_DELEGATED_FROM_ENV] ?? "").trim() || undefined;
100
+ const projectVersion = project?.version ?? null;
101
+ const projectNewer = projectVersion ? (compareSemver(projectVersion, selfVersion) ?? 0) > 0 : false;
102
+ const sameInstall = project ? normalizeRoot(project.packageRoot) === normalizeRoot(input.selfPackageRoot) : false;
103
+
104
+ if (!pinned && !delegatedFrom && project && projectNewer && !sameInstall && project.mcpEntrypoint) {
105
+ return { action: "delegate", entrypoint: project.mcpEntrypoint, fromVersion: selfVersion, toVersion: project.version };
106
+ }
107
+
108
+ const warnings: string[] = [];
109
+ const nextActions: MachineNextAction[] = [];
110
+ if (projectVersion && projectNewer) {
111
+ warnings.push(
112
+ `mcp-older-than-project: swallowkit-mcp ${selfVersion} is older than the project's swallowkit ${projectVersion}. ` +
113
+ "Its capabilities, boundaries, and plan/apply contracts may be outdated for this project."
114
+ );
115
+ nextActions.push({
116
+ command: `${exec} swallowkit machine inspect capabilities`,
117
+ description: "Use the project-local machine interface, which matches the installed SwallowKit version, until MCP is updated.",
118
+ });
119
+ nextActions.push({
120
+ command: `${exec} swallowkit configure-mcp`,
121
+ description: pinned
122
+ ? `Re-select the MCP version (${MCP_VERSION_ENV} is pinned to ${requested}), then restart the MCP server.`
123
+ : "Pin the MCP launcher to the project version, then restart the MCP server.",
124
+ });
125
+ }
126
+
127
+ return {
128
+ action: "serve",
129
+ context: {
130
+ mcpVersion: selfVersion,
131
+ requestedVersion: requested,
132
+ projectVersion,
133
+ selection: delegatedFrom ? "project-version" : pinned ? "pinned" : "running-version",
134
+ delegated: Boolean(delegatedFrom),
135
+ ...(delegatedFrom ? { delegatedFrom } : {}),
136
+ warnings,
137
+ nextActions,
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * For an explicitly pinned launcher, report when the published `latest` has caught up
144
+ * with or passed the pinned version so the pin can be removed.
145
+ */
146
+ export function buildPinnedUpdateNotice(
147
+ context: McpVersionContext,
148
+ publishedLatest: string | null,
149
+ execCommand = "npx"
150
+ ): { warning: string; nextAction: MachineNextAction } | null {
151
+ if (context.selection !== "pinned" || !publishedLatest) return null;
152
+ const comparison = compareSemver(publishedLatest, context.requestedVersion);
153
+ if (comparison === null || comparison < 0) return null;
154
+ return {
155
+ warning:
156
+ `mcp-pin-outdated: ${MCP_VERSION_ENV} is pinned to ${context.requestedVersion}, but published latest is ${publishedLatest}. ` +
157
+ `Switch the launcher back to "${MCP_LATEST}".`,
158
+ nextAction: {
159
+ command: `${execCommand} swallowkit configure-mcp`,
160
+ description: `Re-select the MCP launcher version (returns to "${MCP_LATEST}" when it is at least the project version), then restart the MCP server.`,
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Azure CLI invocation that works without a shell on every platform.
3
+ *
4
+ * On Windows `az` is a batch wrapper (az.cmd). Node cannot spawn it with `shell: false`
5
+ * (ENOENT for "az", EINVAL for "az.cmd"), so the wrapper is resolved explicitly:
6
+ * the MSI layout runs its bundled Python exactly like az.cmd does, and other wrappers
7
+ * run through cmd.exe with every argument quoted.
8
+ */
9
+
10
+ import { spawnSync, SpawnSyncOptions } from "child_process";
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+
14
+ export interface AzCliInvocation {
15
+ command: string;
16
+ prefixArgs: string[];
17
+ env?: NodeJS.ProcessEnv;
18
+ /** Batch wrapper that must be started through cmd.exe. */
19
+ batchScript?: string;
20
+ /** Resolved executable or wrapper, for diagnostics. */
21
+ source: string;
22
+ }
23
+
24
+ export interface AzCliResult {
25
+ status: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ error?: Error;
29
+ }
30
+
31
+ export type AzCliAvailability =
32
+ | { available: true; invocation: AzCliInvocation }
33
+ | { available: false; reason: "not-found" | "not-runnable"; detail: string };
34
+
35
+ function findOnWindowsPath(name: string, env: NodeJS.ProcessEnv): string | null {
36
+ const pathValue = env.PATH ?? env.Path ?? "";
37
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => ext.toLowerCase());
38
+ for (const directory of pathValue.split(";").filter(Boolean)) {
39
+ for (const extension of extensions) {
40
+ const candidate = path.join(directory.replace(/^"|"$/g, ""), `${name}${extension}`);
41
+ try {
42
+ if (fs.statSync(candidate).isFile()) return candidate;
43
+ } catch {
44
+ // Keep searching PATH.
45
+ }
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ export function resolveAzCliInvocation(
52
+ env: NodeJS.ProcessEnv = process.env,
53
+ platform: NodeJS.Platform = process.platform
54
+ ): AzCliInvocation | null {
55
+ if (platform !== "win32") return { command: "az", prefixArgs: [], source: "az" };
56
+
57
+ const resolved = findOnWindowsPath("az", env);
58
+ if (!resolved) return null;
59
+ const extension = path.extname(resolved).toLowerCase();
60
+ if (extension === ".exe" || extension === ".com") {
61
+ return { command: resolved, prefixArgs: [], source: resolved };
62
+ }
63
+
64
+ // MSI install: wbin\az.cmd runs "..\python.exe -IBm azure.cli %*" with AZ_INSTALLER=MSI.
65
+ const bundledPython = path.join(path.dirname(resolved), "..", "python.exe");
66
+ if (path.basename(path.dirname(resolved)).toLowerCase() === "wbin" && fs.existsSync(bundledPython)) {
67
+ return {
68
+ command: path.resolve(bundledPython),
69
+ prefixArgs: ["-IBm", "azure.cli"],
70
+ env: { AZ_INSTALLER: "MSI" },
71
+ source: resolved,
72
+ };
73
+ }
74
+
75
+ return { command: env.ComSpec ?? "cmd.exe", prefixArgs: [], batchScript: resolved, source: resolved };
76
+ }
77
+
78
+ /** Builds the cmd.exe /s /c payload. Rejects characters cmd.exe would reinterpret even inside quotes. */
79
+ export function buildBatchCommandLine(script: string, args: string[]): string {
80
+ for (const value of [script, ...args]) {
81
+ if (/["%\r\n]/.test(value)) {
82
+ throw new Error(`Azure CLI argument cannot be passed safely through ${path.basename(script)}: ${JSON.stringify(value)}`);
83
+ }
84
+ }
85
+ return `"${[script, ...args].map((value) => `"${value}"`).join(" ")}"`;
86
+ }
87
+
88
+ let cachedInvocation: AzCliInvocation | null | undefined;
89
+
90
+ export function getAzCliInvocation(): AzCliInvocation | null {
91
+ if (cachedInvocation === undefined) cachedInvocation = resolveAzCliInvocation();
92
+ return cachedInvocation;
93
+ }
94
+
95
+ export function resetAzCliInvocationCache(): void {
96
+ cachedInvocation = undefined;
97
+ }
98
+
99
+ /** Test hook: pin the resolved invocation (null = not found). */
100
+ export function setAzCliInvocationForTesting(invocation: AzCliInvocation | null | undefined): void {
101
+ cachedInvocation = invocation;
102
+ }
103
+
104
+ export function runAzCli(
105
+ args: string[],
106
+ options: { timeout?: number; invocation?: AzCliInvocation | null } = {}
107
+ ): AzCliResult {
108
+ const invocation = options.invocation === undefined ? getAzCliInvocation() : options.invocation;
109
+ if (!invocation) {
110
+ return { status: null, stdout: "", stderr: "", error: Object.assign(new Error("az was not found on PATH"), { code: "ENOENT" }) };
111
+ }
112
+ const spawnOptions: SpawnSyncOptions = {
113
+ shell: false,
114
+ encoding: "utf-8",
115
+ timeout: options.timeout ?? 300_000,
116
+ env: invocation.env ? { ...process.env, ...invocation.env } : process.env,
117
+ };
118
+ let result;
119
+ try {
120
+ result = invocation.batchScript
121
+ ? spawnSync(invocation.command, ["/d", "/s", "/v:off", "/c", buildBatchCommandLine(invocation.batchScript, args)], {
122
+ ...spawnOptions,
123
+ windowsVerbatimArguments: true,
124
+ })
125
+ : spawnSync(invocation.command, [...invocation.prefixArgs, ...args], spawnOptions);
126
+ } catch (error) {
127
+ return { status: null, stdout: "", stderr: "", error: error instanceof Error ? error : new Error(String(error)) };
128
+ }
129
+ return {
130
+ status: result.status,
131
+ stdout: String(result.stdout ?? ""),
132
+ stderr: String(result.stderr ?? ""),
133
+ ...(result.error ? { error: result.error } : {}),
134
+ };
135
+ }
136
+
137
+ export function checkAzCliAvailability(): AzCliAvailability {
138
+ const invocation = getAzCliInvocation();
139
+ if (!invocation) {
140
+ return { available: false, reason: "not-found", detail: "az was not found on PATH." };
141
+ }
142
+ const result = runAzCli(["--version"], { timeout: 60_000, invocation });
143
+ if (result.status === 0) return { available: true, invocation };
144
+ const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
145
+ if (errorCode === "ENOENT") {
146
+ return { available: false, reason: "not-found", detail: "az was not found on PATH." };
147
+ }
148
+ const output = `${result.stderr || result.stdout}`.trim().split(/\r?\n/).slice(-3).join(" ");
149
+ return {
150
+ available: false,
151
+ reason: "not-runnable",
152
+ detail: `${invocation.source} could not run "az --version" (${result.error?.message ?? `exit ${result.status}`})${output ? `: ${output}` : ""}`,
153
+ };
154
+ }
@@ -0,0 +1,59 @@
1
+ export interface ParsedSemver {
2
+ major: number;
3
+ minor: number;
4
+ patch: number;
5
+ prerelease: Array<string | number>;
6
+ }
7
+
8
+ const SEMVER_PATTERN =
9
+ /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
10
+
11
+ export function parseSemver(value: string | null | undefined): ParsedSemver | null {
12
+ const match = typeof value === "string" ? SEMVER_PATTERN.exec(value.trim()) : null;
13
+ if (!match) return null;
14
+ return {
15
+ major: Number(match[1]),
16
+ minor: Number(match[2]),
17
+ patch: Number(match[3]),
18
+ prerelease: match[4]
19
+ ? match[4].split(".").map((identifier) => (/^\d+$/.test(identifier) ? Number(identifier) : identifier))
20
+ : [],
21
+ };
22
+ }
23
+
24
+ export function isPrerelease(value: string): boolean {
25
+ return (parseSemver(value)?.prerelease.length ?? 0) > 0;
26
+ }
27
+
28
+ /**
29
+ * Compares versions by SemVer 2.0 precedence. Build metadata is ignored.
30
+ * Returns null when either value is not a valid version.
31
+ */
32
+ export function compareSemver(left: string, right: string): number | null {
33
+ const a = parseSemver(left);
34
+ const b = parseSemver(right);
35
+ if (!a || !b) return null;
36
+
37
+ for (const key of ["major", "minor", "patch"] as const) {
38
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
39
+ }
40
+
41
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
42
+ if (a.prerelease.length === b.prerelease.length) return 0;
43
+ return a.prerelease.length === 0 ? 1 : -1;
44
+ }
45
+
46
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
47
+ for (let index = 0; index < length; index++) {
48
+ const leftId = a.prerelease[index];
49
+ const rightId = b.prerelease[index];
50
+ if (leftId === undefined) return -1;
51
+ if (rightId === undefined) return 1;
52
+ if (leftId === rightId) continue;
53
+ if (typeof leftId === "number" && typeof rightId === "number") return leftId < rightId ? -1 : 1;
54
+ if (typeof leftId === "number") return -1;
55
+ if (typeof rightId === "number") return 1;
56
+ return leftId < rightId ? -1 : 1;
57
+ }
58
+ return 0;
59
+ }