stackcanvas 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "stackcanvas",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/server.ts",
6
+ "bin": {
7
+ "stackcanvas": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "ui-dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit",
16
+ "build": "tsup"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.0",
20
+ "@hono/node-server": "^1.13.0",
21
+ "hono": "^4.6.0",
22
+ "ws": "^8.18.0",
23
+ "chokidar": "^4.0.0",
24
+ "zod": "^3.23.0"
25
+ },
26
+ "devDependencies": {
27
+ "@stackcanvas/core": "workspace:*",
28
+ "@stackcanvas/server": "workspace:*",
29
+ "@types/node": "^22.0.0",
30
+ "tsup": "^8.0.0"
31
+ }
32
+ }
package/src/server.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { summarizeGraph } from '@stackcanvas/core'
4
+ import { CanvasServer } from '@stackcanvas/server'
5
+ import { z } from 'zod'
6
+ import { openBrowser as defaultOpenBrowser } from './open-browser.js'
7
+ import { VERSION } from './version.js'
8
+
9
+ function looksLikeTerraformRoot(dir: string): boolean {
10
+ if (!existsSync(dir)) return false
11
+ const entries = readdirSync(dir)
12
+ return entries.some(f => f.endsWith('.tf') || f.endsWith('.tfstate'))
13
+ }
14
+
15
+ const ok = (msg: string) => ({ content: [{ type: 'text' as const, text: msg }] })
16
+ const fail = (msg: string) => ({ content: [{ type: 'text' as const, text: msg }], isError: true })
17
+
18
+ export interface McpDeps {
19
+ makeCanvas?: (dir: string) => CanvasServer
20
+ openBrowser?: (url: string) => void
21
+ }
22
+
23
+ export function createMcpServer(deps: McpDeps = {}): McpServer {
24
+ const makeCanvas = deps.makeCanvas ?? ((dir: string) => new CanvasServer({ dir }))
25
+ const open = deps.openBrowser ?? defaultOpenBrowser
26
+ let canvas: CanvasServer | null = null
27
+ let url: string | null = null
28
+
29
+ const mcp = new McpServer({ name: 'stackcanvas', version: VERSION })
30
+
31
+ mcp.registerTool('open_canvas', {
32
+ description:
33
+ 'Start (or reuse) the stackcanvas live infrastructure canvas for a Terraform root directory. '
34
+ + 'Opens the UI in the browser and returns its URL.',
35
+ inputSchema: { dir: z.string().describe('Absolute path to the Terraform root directory') },
36
+ }, async ({ dir }) => {
37
+ if (!looksLikeTerraformRoot(dir))
38
+ return fail(`${dir} does not look like a Terraform root (no .tf or .tfstate files). `
39
+ + 'Pass the directory that contains the Terraform configuration.')
40
+ if (canvas && canvas.dir !== dir) { await canvas.stop(); canvas = null }
41
+ if (!canvas) {
42
+ const next = makeCanvas(dir)
43
+ try {
44
+ const started = await next.start()
45
+ url = started.url
46
+ } catch (err) {
47
+ await next.stop().catch(() => {})
48
+ return fail(`Failed to start canvas: ${(err as Error).message}`)
49
+ }
50
+ canvas = next
51
+ canvas.setAgentStatus('idle')
52
+ open(url)
53
+ }
54
+ return ok(`Canvas running at ${url}. The graph live-updates as tfstate changes. `
55
+ + 'Run `terraform plan -out=tfplan && terraform show -json tfplan > .stackcanvas/plan.json` '
56
+ + '(or `tofu plan …` with OpenTofu) to show the plan diff, then call await_canvas_intent '
57
+ + 'to receive user edits.')
58
+ })
59
+
60
+ mcp.registerTool('load_plan', {
61
+ description: 'Register a Terraform plan for diff highlighting on the canvas. '
62
+ + 'Accepts a JSON plan (terraform show -json output) or a binary plan file.',
63
+ inputSchema: { path: z.string().describe('Path to plan file (.json preferred)') },
64
+ }, async ({ path }) => {
65
+ if (!canvas) return fail('No canvas open. Call open_canvas first.')
66
+ canvas.setAgentStatus('planning')
67
+ try {
68
+ await canvas.loadPlan(path)
69
+ return ok('Plan loaded; canvas now highlights pending changes.')
70
+ } catch (err) {
71
+ return fail(`Failed to load plan: ${(err as Error).message}`)
72
+ } finally {
73
+ canvas.setAgentStatus('idle')
74
+ }
75
+ })
76
+
77
+ mcp.registerTool('get_graph_summary', {
78
+ description: 'Get a compact text summary of the current infrastructure graph.',
79
+ inputSchema: {},
80
+ }, async () => {
81
+ if (!canvas) return fail('No canvas open. Call open_canvas first.')
82
+ return ok(summarizeGraph(canvas.getGraph()))
83
+ })
84
+
85
+ mcp.registerTool('await_canvas_intent', {
86
+ description: 'Block until the user clicks Apply on the canvas, then return their requested '
87
+ + 'changes as intent JSON: {intent: {add, modify, remove} | null}. null = timeout, call again '
88
+ + 'in a loop to keep waiting. After receiving an intent, write the Terraform code for it.',
89
+ inputSchema: {
90
+ timeoutSeconds: z.number().positive().max(3600).default(45)
91
+ .describe('How long to wait before returning {intent: null}. Default 45s: MCP clients '
92
+ + 'commonly abort requests after 60s, so keep this under your client\'s request timeout '
93
+ + 'and call the tool in a loop instead of passing large values.'),
94
+ },
95
+ }, async ({ timeoutSeconds }) => {
96
+ if (!canvas) return fail('No canvas open. Call open_canvas first.')
97
+ canvas.setAgentStatus('idle')
98
+ const intent = await canvas.awaitIntent(timeoutSeconds * 1000)
99
+ if (intent) canvas.setAgentStatus('writing')
100
+ return ok(JSON.stringify({ intent }))
101
+ })
102
+
103
+ return mcp
104
+ }