ashlr-stack-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/package.json +57 -0
  4. package/src/server.ts +405 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashlr / Mason Wyatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # ashlr-stack-mcp
2
+
3
+ > MCP server for [Ashlr Stack](https://stack.ashlr.ai). Exposes 17 tools and 3 resources so Claude Code / Cursor / Windsurf / Codex can provision every third-party service in a project with one call.
4
+
5
+ ## What it does
6
+
7
+ `stack` is a CLI. `ashlr-stack-mcp` is the same capabilities as MCP tools so your coding agent can drive Stack directly. Ask Claude "add Supabase to this project" and it calls `stack_add`, which walks OAuth, provisions the project, writes secrets through Phantom, and updates `.mcp.json` — all without leaving the chat.
8
+
9
+ ## Install
10
+
11
+ One-liner (installs both `stack` and `ashlr-stack-mcp`):
12
+
13
+ ```bash
14
+ # macOS / Linux
15
+ curl -fsSL stack.ashlr.ai/install.sh | bash
16
+
17
+ # Windows (PowerShell)
18
+ irm stack.ashlr.ai/install.ps1 | iex
19
+ ```
20
+
21
+ Or manually from the registry:
22
+
23
+ ```bash
24
+ bun add -g ashlr-stack-mcp @ashlr/stack
25
+ # or
26
+ npm i -g ashlr-stack-mcp @ashlr/stack
27
+ ```
28
+
29
+ ## Wire it into your agent
30
+
31
+ Drop this into your project's `.mcp.json` (Claude Code, Cursor, Windsurf, Codex — all read the same shape):
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "ashlr-stack": {
37
+ "command": "ashlr-stack-mcp"
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ If you installed from source (no global bin), point at the entry directly:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "ashlr-stack": {
49
+ "command": "bun",
50
+ "args": ["run", "/absolute/path/to/ashlr-stack/packages/mcp/src/server.ts"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Tools (17)
57
+
58
+ Provisioning: `stack_init`, `stack_add`, `stack_remove`, `stack_import`, `stack_scan`.
59
+
60
+ Inspection: `stack_list`, `stack_info`, `stack_providers`, `stack_deps`, `stack_doctor`.
61
+
62
+ Env: `stack_env_show`, `stack_env_diff`, `stack_env_set`, `stack_env_unset`.
63
+
64
+ Project registry: `stack_projects_list`, `stack_upgrade`.
65
+
66
+ ## Resources (3)
67
+
68
+ - `stack://current/.stack.toml`
69
+ - `stack://current/.stack.local.toml`
70
+ - `stack://current/.mcp.json`
71
+
72
+ The agent can read these directly — no tool call needed — so it always knows the project state before acting.
73
+
74
+ Full tool + resource reference: **[stack.ashlr.ai/docs/mcp](https://stack.ashlr.ai/docs/mcp)**.
75
+
76
+ ## How secrets are handled
77
+
78
+ Stack never stores secret values. Every tool call routes through [Phantom Secrets](https://phantom.ashlr.ai) on your machine. The agent only sees slot *names* (like `SUPABASE_URL`, `SUPABASE_ANON_KEY`) — never the values.
79
+
80
+ ## Links
81
+
82
+ - Docs — [stack.ashlr.ai/docs](https://stack.ashlr.ai/docs)
83
+ - MCP reference — [stack.ashlr.ai/docs/mcp](https://stack.ashlr.ai/docs/mcp)
84
+ - Repo — [github.com/ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack)
85
+ - Issues — [github.com/ashlrai/ashlr-stack/issues](https://github.com/ashlrai/ashlr-stack/issues)
86
+
87
+ ## License
88
+
89
+ MIT. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "ashlr-stack-mcp",
3
+ "version": "0.1.1",
4
+ "description": "MCP server for Ashlr Stack. Exposes 17 tools and 3 resources so Claude Code / Cursor / Windsurf / Codex can provision every third-party service in a project with one call.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Mason Wyatt",
8
+ "url": "https://github.com/ashlrai"
9
+ },
10
+ "homepage": "https://stack.ashlr.ai",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ashlrai/ashlr-stack.git",
14
+ "directory": "packages/mcp"
15
+ },
16
+ "bugs": "https://github.com/ashlrai/ashlr-stack/issues",
17
+ "keywords": [
18
+ "ashlr",
19
+ "stack",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "claude-code",
23
+ "cursor",
24
+ "windsurf",
25
+ "codex",
26
+ "ashlrcode",
27
+ "agent"
28
+ ],
29
+ "type": "module",
30
+ "bin": {
31
+ "ashlr-stack-mcp": "./src/server.ts"
32
+ },
33
+ "files": [
34
+ "src/**/*",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.2.0",
44
+ "node": ">=20"
45
+ },
46
+ "scripts": {
47
+ "start": "bun run src/server.ts",
48
+ "typecheck": "bunx tsc --noEmit"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest",
55
+ "typescript": "^5.7.0"
56
+ }
57
+ }
package/src/server.ts ADDED
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * ashlr-stack MCP server.
4
+ *
5
+ * Exposes every major `stack` CLI command as an MCP tool, plus the current
6
+ * project's .stack.toml as an MCP resource so Claude can read it without a
7
+ * tool call. Keeps the server thin — all behaviour lives in the CLI.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListResourcesRequestSchema,
18
+ ListToolsRequestSchema,
19
+ ReadResourceRequestSchema,
20
+ } from "@modelcontextprotocol/sdk/types.js";
21
+
22
+ interface ToolDef {
23
+ name: string;
24
+ description: string;
25
+ inputSchema: {
26
+ type: "object";
27
+ properties: Record<string, { type: string; description?: string }>;
28
+ required?: string[];
29
+ };
30
+ cliArgs: (input: Record<string, unknown>) => string[];
31
+ }
32
+
33
+ const TOOLS: ToolDef[] = [
34
+ {
35
+ name: "stack_init",
36
+ description: "Scaffold a new .stack.toml in the current directory.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ template: { type: "string", description: "Optional starter template name." },
41
+ force: { type: "boolean", description: "Overwrite an existing .stack.toml." },
42
+ },
43
+ },
44
+ cliArgs: (input) => {
45
+ const args = ["init", "--noInteractive"];
46
+ if (input.template) args.push("--template", String(input.template));
47
+ if (input.force) args.push("--force");
48
+ return args;
49
+ },
50
+ },
51
+ {
52
+ name: "stack_import",
53
+ description: "Import an existing .env file into Phantom + .stack.toml.",
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: {
57
+ from: { type: "string", description: "Path to the .env file (default: .env)." },
58
+ dryRun: { type: "boolean", description: "Preview without writing." },
59
+ },
60
+ },
61
+ cliArgs: (input) => {
62
+ const args = ["import"];
63
+ if (input.from) args.push("--from", String(input.from));
64
+ if (input.dryRun) args.push("--dryRun");
65
+ return args;
66
+ },
67
+ },
68
+ {
69
+ name: "stack_scan",
70
+ description:
71
+ "Detect providers this repo uses by reading package.json / requirements.txt / config files.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ path: { type: "string", description: "Directory to scan (default: cwd)." },
76
+ confidence: {
77
+ type: "string",
78
+ description: "Minimum confidence: low, medium, or high (default: medium).",
79
+ },
80
+ },
81
+ },
82
+ cliArgs: (input) => {
83
+ const args = ["scan"];
84
+ if (input.path) args.push("--path", String(input.path));
85
+ if (input.confidence) args.push("--confidence", String(input.confidence));
86
+ return args;
87
+ },
88
+ },
89
+ {
90
+ name: "stack_add",
91
+ description: "Provision a service and wire its secrets + MCP entry.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ service: { type: "string", description: "Service name (e.g. supabase, neon, vercel)." },
96
+ use: { type: "string", description: "Existing resource id to attach to." },
97
+ },
98
+ required: ["service"],
99
+ },
100
+ cliArgs: (input) => {
101
+ const args = ["add", String(input.service)];
102
+ if (input.use) args.push("--use", String(input.use));
103
+ return args;
104
+ },
105
+ },
106
+ {
107
+ name: "stack_remove",
108
+ description: "Remove a service from the stack (vault entries and MCP config).",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ service: { type: "string" },
113
+ keepRemote: { type: "boolean" },
114
+ },
115
+ required: ["service"],
116
+ },
117
+ cliArgs: (input) => {
118
+ const args = ["remove", String(input.service)];
119
+ if (input.keepRemote) args.push("--keepRemote");
120
+ return args;
121
+ },
122
+ },
123
+ {
124
+ name: "stack_list",
125
+ description: "List services configured in this stack.",
126
+ inputSchema: { type: "object", properties: {} },
127
+ cliArgs: () => ["list"],
128
+ },
129
+ {
130
+ name: "stack_info",
131
+ description:
132
+ "Deep-dive on a single service: resource, region, auth, secrets, MCP wiring, health.",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: { service: { type: "string" } },
136
+ required: ["service"],
137
+ },
138
+ cliArgs: (input) => ["info", String(input.service)],
139
+ },
140
+ {
141
+ name: "stack_status",
142
+ description: "Show Phantom + services + config at a glance.",
143
+ inputSchema: { type: "object", properties: {} },
144
+ cliArgs: () => ["status"],
145
+ },
146
+ {
147
+ name: "stack_env_show",
148
+ description: "Show which declared secrets are present in the Phantom vault (masked).",
149
+ inputSchema: { type: "object", properties: {} },
150
+ cliArgs: () => ["env", "show"],
151
+ },
152
+ {
153
+ name: "stack_env_diff",
154
+ description: "Report which declared secrets are missing from the Phantom vault.",
155
+ inputSchema: { type: "object", properties: {} },
156
+ cliArgs: () => ["env", "diff"],
157
+ },
158
+ {
159
+ name: "stack_doctor",
160
+ description: "Verify every service is reachable and credentials are valid.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ fix: { type: "boolean", description: "Attempt auto-remediation." },
165
+ all: { type: "boolean", description: "Run across every registered project." },
166
+ json: { type: "boolean", description: "Machine-readable JSON output." },
167
+ },
168
+ },
169
+ cliArgs: (input) => {
170
+ const args = ["doctor"];
171
+ if (input.fix) args.push("--fix");
172
+ if (input.all) args.push("--all");
173
+ if (input.json) args.push("--json");
174
+ return args;
175
+ },
176
+ },
177
+ {
178
+ name: "stack_sync",
179
+ description: "Push secrets to a deployment platform (via `phantom sync`).",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: { platform: { type: "string" } },
183
+ required: ["platform"],
184
+ },
185
+ cliArgs: (input) => ["sync", "--platform", String(input.platform)],
186
+ },
187
+ {
188
+ name: "stack_providers",
189
+ description: "List every curated provider Stack can wire up (grouped by category).",
190
+ inputSchema: { type: "object", properties: {} },
191
+ cliArgs: () => ["providers"],
192
+ },
193
+ {
194
+ name: "stack_projects_list",
195
+ description: "List every Stack-enabled project on this machine.",
196
+ inputSchema: { type: "object", properties: {} },
197
+ cliArgs: () => ["projects", "list"],
198
+ },
199
+ {
200
+ name: "stack_deps",
201
+ description: "Show the service dependency graph for the current stack.",
202
+ inputSchema: { type: "object", properties: {} },
203
+ cliArgs: () => ["deps"],
204
+ },
205
+ {
206
+ name: "stack_templates_list",
207
+ description: "List available starter stack templates.",
208
+ inputSchema: { type: "object", properties: {} },
209
+ cliArgs: () => ["templates", "list"],
210
+ },
211
+ {
212
+ name: "stack_upgrade",
213
+ description: "Check npm for a newer @ashlr/stack release.",
214
+ inputSchema: { type: "object", properties: {} },
215
+ cliArgs: () => ["upgrade"],
216
+ },
217
+ {
218
+ name: "stack_recommend",
219
+ description:
220
+ "Given a free-text description of what the user is building (e.g. 'B2B SaaS with auth, AI, and payments'), return a structured list of the most relevant curated providers with scores, matched terms, and per-category ranking. Pass save:true to also persist a Recipe to .stack/recipes/<id>.toml so you (Claude) can follow up with stack_apply. Retrieval-only — no LLM inference happens here.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ query: {
225
+ type: "string",
226
+ description: "Natural-language description of the project / stack need.",
227
+ },
228
+ k: {
229
+ type: "number",
230
+ description: "Max top-level hits to return (default 6).",
231
+ },
232
+ category: {
233
+ type: "string",
234
+ description:
235
+ "Optional filter to a single category: Database, Deploy, Cloud, AI, Analytics, Errors, Payments, Code, Tickets, Email, Auth.",
236
+ },
237
+ save: {
238
+ type: "boolean",
239
+ description:
240
+ "When true, also freeze the result to .stack/recipes/<id>.toml so you can run stack_apply. Returns the recipe id in the response.",
241
+ },
242
+ },
243
+ required: ["query"],
244
+ },
245
+ cliArgs: (input) => {
246
+ const args = ["recommend", String(input.query ?? ""), "--json"];
247
+ if (input.k) args.push("--k", String(input.k));
248
+ if (input.category) args.push("--category", String(input.category));
249
+ if (input.save) args.push("--save");
250
+ return args;
251
+ },
252
+ },
253
+ {
254
+ name: "stack_apply",
255
+ description:
256
+ "Apply a saved Recipe: replay `stack add` for each provider, then pre-wire Phantom rotation envelopes + webhook stubs. Set `no_wire: true` to opt out of the Phantom-wire layer (stays pure-provisioning).",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ recipe_id: {
261
+ type: "string",
262
+ description: "Recipe id (filename stem in .stack/recipes/).",
263
+ },
264
+ no_wire: {
265
+ type: "boolean",
266
+ description: "Skip Phantom envelope + webhook pre-wiring.",
267
+ },
268
+ },
269
+ required: ["recipe_id"],
270
+ },
271
+ cliArgs: (input) => {
272
+ const args = ["apply", String(input.recipe_id)];
273
+ if (input.no_wire) args.push("--noWire");
274
+ return args;
275
+ },
276
+ },
277
+ ];
278
+
279
+ const STACK_BIN = process.env.STACK_BIN ?? "stack";
280
+
281
+ function runStack(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
282
+ return new Promise((resolve) => {
283
+ const child = spawn(STACK_BIN, args, {
284
+ env: { ...process.env, NO_COLOR: "1", CI: "1" },
285
+ stdio: ["ignore", "pipe", "pipe"],
286
+ });
287
+ let stdout = "";
288
+ let stderr = "";
289
+ child.stdout?.on("data", (chunk) => {
290
+ stdout += chunk.toString();
291
+ });
292
+ child.stderr?.on("data", (chunk) => {
293
+ stderr += chunk.toString();
294
+ });
295
+ child.on("error", (err) => {
296
+ // Surface PATH/spawn failures as a structured text result rather than
297
+ // rejecting — the MCP caller gets a clear "is `stack` on your PATH?"
298
+ // hint instead of an opaque tool-execution error.
299
+ const code = (err as NodeJS.ErrnoException).code;
300
+ const hint =
301
+ code === "ENOENT"
302
+ ? `The 'stack' CLI is not on PATH. Install it (bun add -g @ashlr/stack) or set STACK_BIN to an absolute path.`
303
+ : (err as Error).message;
304
+ resolve({ stdout: "", stderr: hint, code: 127 });
305
+ });
306
+ child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
307
+ });
308
+ }
309
+
310
+ const server = new Server(
311
+ { name: "ashlr-stack", version: "0.1.0" },
312
+ { capabilities: { tools: {}, resources: {} } },
313
+ );
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Tools
317
+ // ---------------------------------------------------------------------------
318
+
319
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
320
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
321
+ }));
322
+
323
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
324
+ const tool = TOOLS.find((t) => t.name === request.params.name);
325
+ if (!tool) {
326
+ return {
327
+ content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
328
+ isError: true,
329
+ };
330
+ }
331
+ const input = (request.params.arguments ?? {}) as Record<string, unknown>;
332
+ const args = tool.cliArgs(input);
333
+ const { stdout, stderr, code } = await runStack(args);
334
+ const body = stdout.trim() + (stderr.trim() ? `\n\n[stderr]\n${stderr.trim()}` : "");
335
+ return {
336
+ content: [{ type: "text", text: body || `(stack ${args.join(" ")} exit ${code})` }],
337
+ isError: code !== 0,
338
+ };
339
+ });
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Resources — let Claude read .stack.toml without a tool call
343
+ // ---------------------------------------------------------------------------
344
+
345
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
346
+ const resources: Array<{
347
+ uri: string;
348
+ name: string;
349
+ description: string;
350
+ mimeType: string;
351
+ }> = [];
352
+ const cwd = process.cwd();
353
+ if (existsSync(join(cwd, ".stack.toml"))) {
354
+ resources.push({
355
+ uri: "stack://current/.stack.toml",
356
+ name: ".stack.toml (this project)",
357
+ description: "Committed shape of the current project's stack.",
358
+ mimeType: "application/toml",
359
+ });
360
+ }
361
+ if (existsSync(join(cwd, ".stack.local.toml"))) {
362
+ resources.push({
363
+ uri: "stack://current/.stack.local.toml",
364
+ name: ".stack.local.toml (local instance)",
365
+ description: "Local instance data (resource ids, project_id). Gitignored.",
366
+ mimeType: "application/toml",
367
+ });
368
+ }
369
+ if (existsSync(join(cwd, ".mcp.json"))) {
370
+ resources.push({
371
+ uri: "stack://current/.mcp.json",
372
+ name: ".mcp.json (MCP wiring)",
373
+ description: "Currently-wired MCP servers.",
374
+ mimeType: "application/json",
375
+ });
376
+ }
377
+ return { resources };
378
+ });
379
+
380
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
381
+ const uri = request.params.uri;
382
+ const match = uri.match(/^stack:\/\/current\/(.+)$/);
383
+ if (!match) {
384
+ throw new Error(`Unknown resource uri: ${uri}`);
385
+ }
386
+ const rel = match[1];
387
+ if (![".stack.toml", ".stack.local.toml", ".mcp.json"].includes(rel)) {
388
+ throw new Error(`Refusing to read: ${rel}`);
389
+ }
390
+ const path = join(process.cwd(), rel);
391
+ if (!existsSync(path)) throw new Error(`${rel} does not exist in cwd.`);
392
+ const text = readFileSync(path, "utf-8");
393
+ return {
394
+ contents: [
395
+ {
396
+ uri,
397
+ mimeType: rel.endsWith(".json") ? "application/json" : "application/toml",
398
+ text,
399
+ },
400
+ ],
401
+ };
402
+ });
403
+
404
+ const transport = new StdioServerTransport();
405
+ await server.connect(transport);