@hoststack.dev/mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1418 @@
1
+ // src/server-factory.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { HostStack } from "@hoststack.dev/sdk";
4
+
5
+ // src/api-client.ts
6
+ var ApiClient = class {
7
+ constructor(apiKey, baseUrl) {
8
+ this.apiKey = apiKey;
9
+ this.baseUrl = baseUrl;
10
+ }
11
+ get headers() {
12
+ return {
13
+ Authorization: `Bearer ${this.apiKey}`,
14
+ "Content-Type": "application/json"
15
+ };
16
+ }
17
+ async get(path, params) {
18
+ const url = new URL(`${this.baseUrl}${path}`);
19
+ if (params) {
20
+ for (const [key, value] of Object.entries(params)) {
21
+ if (value !== void 0) url.searchParams.set(key, String(value));
22
+ }
23
+ }
24
+ const res = await fetch(url.toString(), { headers: this.headers });
25
+ return this.handle(res);
26
+ }
27
+ async post(path, body) {
28
+ const init = {
29
+ method: "POST",
30
+ headers: this.headers
31
+ };
32
+ if (body !== void 0) init.body = JSON.stringify(body);
33
+ const res = await fetch(`${this.baseUrl}${path}`, init);
34
+ return this.handle(res);
35
+ }
36
+ async patch(path, body) {
37
+ const res = await fetch(`${this.baseUrl}${path}`, {
38
+ method: "PATCH",
39
+ headers: this.headers,
40
+ body: JSON.stringify(body)
41
+ });
42
+ return this.handle(res);
43
+ }
44
+ async delete(path) {
45
+ const res = await fetch(`${this.baseUrl}${path}`, {
46
+ method: "DELETE",
47
+ headers: this.headers
48
+ });
49
+ return this.handle(res);
50
+ }
51
+ async handle(res) {
52
+ if (!res.ok) {
53
+ const error = await res.json().catch(() => ({ error: res.statusText }));
54
+ throw new Error(error.error ?? `API error: ${res.status}`);
55
+ }
56
+ if (res.status === 204) {
57
+ return void 0;
58
+ }
59
+ return res.json();
60
+ }
61
+ };
62
+
63
+ // src/prompts/registry.ts
64
+ var prompts = [];
65
+ function definePrompt(def) {
66
+ prompts.push({
67
+ name: def.name,
68
+ description: def.description,
69
+ args: def.args,
70
+ handler: def.handler
71
+ });
72
+ }
73
+ function listPromptDefinitions() {
74
+ return prompts;
75
+ }
76
+ function attachPrompts(server, ctx) {
77
+ const register = server.prompt.bind(server);
78
+ for (const def of prompts) {
79
+ register(def.name, def.description, def.args, (args) => def.handler(args, ctx));
80
+ }
81
+ }
82
+ function userMessage(text) {
83
+ return { role: "user", content: { type: "text", text } };
84
+ }
85
+
86
+ // src/resources/registry.ts
87
+ var resources = [];
88
+ function defineResource(def) {
89
+ resources.push(def);
90
+ }
91
+ function listResourceDefinitions() {
92
+ return resources;
93
+ }
94
+ function attachResources(server, ctx) {
95
+ for (const def of resources) {
96
+ server.resource(
97
+ def.name,
98
+ def.uri,
99
+ {
100
+ description: def.description,
101
+ ...def.mimeType ? { mimeType: def.mimeType } : {}
102
+ },
103
+ (uri) => def.read(uri, ctx)
104
+ );
105
+ }
106
+ }
107
+ function jsonResource(uri, data) {
108
+ return {
109
+ contents: [
110
+ {
111
+ uri: uri.toString(),
112
+ mimeType: "application/json",
113
+ text: JSON.stringify(data, null, 2)
114
+ }
115
+ ]
116
+ };
117
+ }
118
+
119
+ // src/registry.ts
120
+ var tools = [];
121
+ function defineTool(def) {
122
+ tools.push({
123
+ name: def.name,
124
+ category: def.category,
125
+ description: def.description,
126
+ input: def.input,
127
+ handler: def.handler
128
+ });
129
+ }
130
+ function listToolDefinitions() {
131
+ return tools;
132
+ }
133
+ function attachTools(server, ctx, sink) {
134
+ const register = server.tool.bind(server);
135
+ for (const def of tools) {
136
+ register(def.name, def.description, def.input, async (args) => {
137
+ const startedAt = /* @__PURE__ */ new Date();
138
+ const start = performance.now();
139
+ let ok = true;
140
+ let errorCode = null;
141
+ try {
142
+ const result = await def.handler(args, ctx);
143
+ if (result.isError) {
144
+ ok = false;
145
+ errorCode = "tool_error";
146
+ }
147
+ return result;
148
+ } catch (err) {
149
+ ok = false;
150
+ errorCode = err instanceof Error ? (err.name || "Error").slice(0, 64) : "unknown_error";
151
+ throw err;
152
+ } finally {
153
+ if (sink) {
154
+ const durationMs = Math.round(performance.now() - start);
155
+ const event = {
156
+ tool: def.name,
157
+ durationMs,
158
+ ok,
159
+ errorCode,
160
+ inputHash: hashArgs(args),
161
+ startedAt
162
+ };
163
+ Promise.resolve().then(() => sink(event)).catch(() => void 0);
164
+ }
165
+ }
166
+ });
167
+ }
168
+ }
169
+ function hashArgs(args) {
170
+ try {
171
+ const json = JSON.stringify(args);
172
+ const bunGlobal = globalThis.Bun;
173
+ if (bunGlobal?.hash) {
174
+ return bunGlobal.hash(json).toString(16);
175
+ }
176
+ return `len:${json.length}`;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // src/prompts/deploy-prompts.ts
183
+ import { z } from "zod";
184
+ definePrompt({
185
+ name: "diagnose_failed_deploy",
186
+ description: "Walk the agent through diagnosing a failed deploy: pull the latest deploys for the named service, find the most recent failure, fetch its build/deploy logs, and propose a fix. Stops at suggestion \u2014 does not retrigger automatically.",
187
+ args: {
188
+ service_id: z.string().describe("Service publicId (e.g. svc_abc123).")
189
+ },
190
+ handler: async ({ service_id }) => {
191
+ const text = `Goal: diagnose the most recent failed deploy on service ${service_id} and propose a fix.
192
+
193
+ Plan (use these tools in order):
194
+ 1. \`list_deploys({ service_id: "${service_id}" })\` \u2014 pull recent deploys, newest first. Identify the most recent deploy with status \`failed\` (or \`cancelled\` if the user wants to investigate that too). Capture its publicId, branch, and commitSha.
195
+ 2. \`get_deploy_logs({ service_id: "${service_id}", deploy_id: "<dpl_\u2026>" })\` \u2014 read the full build output. Scan for: stack traces, "ERROR" / "error:" lines, exit codes, missing env vars, OOM-killed signals, network failures pulling dependencies.
196
+ 3. \`get_service({ service_id: "${service_id}" })\` \u2014 confirm the service's runtime, plan, and whether autoDeploy is on. Plan tier matters because OOMs at small tiers point at \`maxMemoryMb\`.
197
+ 4. (Optional) \`list_env_vars({ service_id: "${service_id}" })\` \u2014 only if the build error mentions a missing variable. Don't fetch otherwise; values are masked anyway.
198
+
199
+ Then write a short diagnosis for the user with three sections:
200
+ - **Root cause** \u2014 one sentence pointing at the actual failure line.
201
+ - **Evidence** \u2014 3\u20136 line excerpt from the logs that proves the diagnosis.
202
+ - **Suggested fix** \u2014 concrete next steps, including which tool to call (e.g. "set MISSING_KEY with set_env_var, then trigger_deploy"). Do NOT call trigger_deploy automatically; the user decides.
203
+
204
+ If the deploy logs are empty or only contain queued/pending markers, surface that \u2014 the build never started, which is its own class of bug (likely a builder-pool or quota issue).`;
205
+ return { messages: [userMessage(text)] };
206
+ }
207
+ });
208
+ definePrompt({
209
+ name: "deploy_status_check",
210
+ description: "Quick orientation prompt: who am I, what's running, and is anything currently deploying? Useful at the start of a session.",
211
+ args: {},
212
+ handler: async () => {
213
+ const text = `Goal: produce a 30-second status check on this HostStack team.
214
+
215
+ Plan:
216
+ 1. \`get_me()\` \u2014 confirm which user + team the API key belongs to.
217
+ 2. \`list_services()\` \u2014 every service with current status (running, suspended, building, deploying, failed). Note any service in a non-running state.
218
+ 3. For each service whose status is \`building\` or \`deploying\`, call \`list_deploys({ service_id })\` and surface the latest deploy's commitMessage + author.
219
+ 4. \`list_activity_log({ per_page: 10 })\` \u2014 last 10 audit events to spot recent changes (deploys triggered, env vars edited, services restarted).
220
+
221
+ Write a status report shaped like:
222
+
223
+ > **Team**: <name>
224
+ > **Services**: <n> total \u2014 <breakdown by status>
225
+ > **In flight**: <list of building/deploying services with commit info, or "none">
226
+ > **Recent activity**: <3-5 most relevant events from the audit log, newest first>
227
+
228
+ Keep it tight (under 200 words). The user can drill in with follow-up tools.`;
229
+ return { messages: [userMessage(text)] };
230
+ }
231
+ });
232
+ definePrompt({
233
+ name: "rotate_secret",
234
+ description: "Guide the agent through rotating an env-var secret on a service. Lists current vars to confirm the target key exists, asks the user for the new value, sets it, and offers to trigger a deploy.",
235
+ args: {
236
+ service_id: z.string().describe("Service publicId."),
237
+ key: z.string().describe("Env-var key to rotate (e.g. DATABASE_URL).")
238
+ },
239
+ handler: async ({ service_id, key }) => {
240
+ const text = `Goal: rotate the secret \`${key}\` on service ${service_id}.
241
+
242
+ Plan:
243
+ 1. \`list_env_vars({ service_id: "${service_id}" })\` \u2014 confirm \`${key}\` exists. Note its \`isSecret\` flag (it should be true; if not, flag this \u2014 you're about to mark it secret on rotation, which is a behaviour change).
244
+ 2. **Ask the user for the new value before calling any write tool.** The agent must NOT invent or generate the secret; it has to come from the user's clipboard / vault. Phrase the ask explicitly: "Paste the new value for ${key} (it will be encrypted at rest and masked everywhere except the running container)."
245
+ 3. Once the user provides the value:
246
+ - \`set_env_var({ service_id: "${service_id}", key: "${key}", value: "<new value>", is_secret: true })\` \u2014 upserts by key.
247
+ 4. After the write succeeds, ask the user whether to redeploy (so the new value lands in the running container). If yes:
248
+ - \`trigger_deploy({ service_id: "${service_id}" })\` \u2014 kicks off a build with the rotated secret.
249
+ - \`get_deploy({ service_id: "${service_id}", deploy_id: "<dpl_\u2026>" })\` \u2014 poll until status is \`live\` or \`failed\`. On failure, hand off to \`diagnose_failed_deploy\`.
250
+
251
+ Safety rails:
252
+ - Never echo the new value back in your reply; the user should verify it from their own source of truth, not from your context window.
253
+ - If the user says "generate a new one", suggest \`openssl rand -hex 32\` (or similar) for them to run locally and paste in. Do NOT generate it for them \u2014 secrets that pass through an LLM context are no longer secrets.
254
+ - If \`set_env_var\` returned \`action: "created"\` instead of \`"updated"\`, the key didn't exist before. Pause and confirm with the user \u2014 they may have typo'd the key name.`;
255
+ return { messages: [userMessage(text)] };
256
+ }
257
+ });
258
+
259
+ // src/lib/shape.ts
260
+ var INTERNAL_FIELDS = /* @__PURE__ */ new Set([
261
+ "teamId",
262
+ "team_id",
263
+ "userId",
264
+ "user_id",
265
+ "accountId",
266
+ "account_id",
267
+ "tenantId",
268
+ "tenant_id",
269
+ "createdById",
270
+ "updatedById"
271
+ ]);
272
+ function isObject(value) {
273
+ return value !== null && typeof value === "object" && !Array.isArray(value);
274
+ }
275
+ function dropNullsAndInternals(obj) {
276
+ const out = {};
277
+ for (const [key, value] of Object.entries(obj)) {
278
+ if (INTERNAL_FIELDS.has(key)) continue;
279
+ if (value === null || value === void 0) continue;
280
+ out[key] = value;
281
+ }
282
+ return out;
283
+ }
284
+ function shape(value) {
285
+ if (Array.isArray(value)) return value.map(shape);
286
+ if (isObject(value)) return dropNullsAndInternals(value);
287
+ return value;
288
+ }
289
+ function shapeAll(arr, shaper) {
290
+ if (!Array.isArray(arr)) return [];
291
+ return arr.map(shaper);
292
+ }
293
+ function shapeList(response, key, shaper) {
294
+ if (!isObject(response)) return { items: [] };
295
+ return { items: shapeAll(response[key], shaper) };
296
+ }
297
+ function shapeProject(value) {
298
+ return isObject(value) ? dropNullsAndInternals(value) : {};
299
+ }
300
+ function shapeService(value) {
301
+ return isObject(value) ? dropNullsAndInternals(value) : {};
302
+ }
303
+ function shapeDeploy(value) {
304
+ return isObject(value) ? dropNullsAndInternals(value) : {};
305
+ }
306
+ function shapeDatabase(value) {
307
+ return isObject(value) ? dropNullsAndInternals(value) : {};
308
+ }
309
+ function shapeDomain(value) {
310
+ return isObject(value) ? dropNullsAndInternals(value) : {};
311
+ }
312
+ function shapeEnvVar(value) {
313
+ return isObject(value) ? dropNullsAndInternals(value) : {};
314
+ }
315
+ function shapeCronExecution(value) {
316
+ return isObject(value) ? dropNullsAndInternals(value) : {};
317
+ }
318
+ function shapeActivity(value) {
319
+ return isObject(value) ? dropNullsAndInternals(value) : {};
320
+ }
321
+ function shapeUser(value) {
322
+ return isObject(value) ? dropNullsAndInternals(value) : {};
323
+ }
324
+ function shapeTeam(value) {
325
+ return isObject(value) ? dropNullsAndInternals(value) : {};
326
+ }
327
+
328
+ // src/resources/hoststack-resources.ts
329
+ defineResource({
330
+ kind: "static",
331
+ name: "team",
332
+ uri: "hoststack://team",
333
+ description: "Authenticated team identity + a quick orientation snapshot. Includes the team record, the user behind the API key, and counts of projects and services so an agent can decide whether to fan out into list_projects / list_services without an extra round-trip.",
334
+ mimeType: "application/json",
335
+ read: async (uri, { hoststack, api, resolveTeamId }) => {
336
+ const teamId = await resolveTeamId();
337
+ const [me, projectsResult, servicesResult] = await Promise.all([
338
+ api.get("/api/auth/me").catch(() => null),
339
+ hoststack.projects.list(teamId).catch(() => ({ projects: [] })),
340
+ hoststack.services.list(teamId).catch(() => ({ services: [] }))
341
+ ]);
342
+ const projects = projectsResult.projects.slice(0, 10).map(shapeProject);
343
+ const services = servicesResult.services.slice(0, 10).map(shapeService);
344
+ return jsonResource(uri, {
345
+ user: me?.user ? shapeUser(me.user) : null,
346
+ team: me?.team ? shapeTeam(me.team) : null,
347
+ project_count: projectsResult.projects.length,
348
+ service_count: servicesResult.services.length,
349
+ // First 10 of each so the resource stays small while still useful
350
+ // for orientation. Use list_projects / list_services for the full set.
351
+ projects_preview: projects,
352
+ services_preview: services
353
+ });
354
+ }
355
+ });
356
+
357
+ // src/tools/activity-log.ts
358
+ import { z as z2 } from "zod";
359
+
360
+ // src/lib/respond.ts
361
+ var SUMMARY_MAX_LEN = 500;
362
+ function respond({ summary, data }) {
363
+ const trimmed = summary.trim();
364
+ if (trimmed.length === 0) {
365
+ throw new Error("respond(): summary must be non-empty");
366
+ }
367
+ if (trimmed.length > SUMMARY_MAX_LEN) {
368
+ throw new Error(`respond(): summary too long (${trimmed.length} > ${SUMMARY_MAX_LEN})`);
369
+ }
370
+ const blocks = [{ type: "text", text: trimmed }];
371
+ if (data !== void 0) {
372
+ blocks.push({
373
+ type: "text",
374
+ text: "```json\n" + JSON.stringify(data, null, 2) + "\n```"
375
+ });
376
+ }
377
+ const result = { content: blocks };
378
+ if (data !== void 0) {
379
+ result.structuredContent = toStructuredContent(data);
380
+ }
381
+ return result;
382
+ }
383
+ function respondError(message, data) {
384
+ const blocks = [{ type: "text", text: `Error: ${message}` }];
385
+ if (data !== void 0) {
386
+ blocks.push({
387
+ type: "text",
388
+ text: "```json\n" + JSON.stringify(data, null, 2) + "\n```"
389
+ });
390
+ }
391
+ const result = { content: blocks, isError: true };
392
+ if (data !== void 0) {
393
+ result.structuredContent = toStructuredContent(data);
394
+ }
395
+ return result;
396
+ }
397
+ function toStructuredContent(data) {
398
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) {
399
+ return data;
400
+ }
401
+ return { result: data };
402
+ }
403
+
404
+ // src/tools/activity-log.ts
405
+ defineTool({
406
+ name: "list_activity_log",
407
+ category: "activity-log",
408
+ description: [
409
+ "List the team activity audit log: who did what, when. Backed by /api/activity-log/:teamId.",
410
+ "",
411
+ 'When to use: investigate why a service was changed unexpectedly ("who deleted that env var?"), correlate a deploy with a user, or pull recent admin events for a status update.',
412
+ "",
413
+ "Inputs (all optional):",
414
+ " - page: 1-based page index (default 1).",
415
+ " - per_page: items per page (default 25, hard cap 100).",
416
+ ' - action: filter to a specific action (e.g. "service.created", "deploy.triggered").',
417
+ ' - resource_type: filter by resource type (e.g. "service", "deploy", "domain").',
418
+ " - user_id: filter by acting user numeric ID.",
419
+ "",
420
+ "Returns: { items: ActivityLogEntry[], meta? } \u2014 each entry has id, action, resourceType, resourceId, actorEmail, ipAddress, createdAt, and a context payload.",
421
+ "",
422
+ 'Example: list_activity_log({ resource_type: "deploy", per_page: 10 }) \u2192 { items: [{ action: "deploy.triggered", actorEmail: "ada@\u2026", \u2026 }], meta: { total: 47, page: 1 } }'
423
+ ].join("\n"),
424
+ input: {
425
+ page: z2.number().int().positive().optional().describe("Page index, 1-based."),
426
+ per_page: z2.number().int().positive().max(100).optional().describe("Items per page, hard cap 100."),
427
+ action: z2.string().optional().describe('Action filter, e.g. "service.created".'),
428
+ resource_type: z2.string().optional().describe("Resource type filter."),
429
+ user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter.")
430
+ },
431
+ handler: async (args, ctx) => {
432
+ const teamId = await ctx.resolveTeamId();
433
+ const params = {};
434
+ if (args.page !== void 0) params["page"] = String(args.page);
435
+ if (args.per_page !== void 0) params["perPage"] = String(args.per_page);
436
+ if (args.action !== void 0) params["action"] = args.action;
437
+ if (args.resource_type !== void 0) params["resourceType"] = args.resource_type;
438
+ if (args.user_id !== void 0) params["userId"] = String(args.user_id);
439
+ const response = await ctx.api.get(
440
+ `/api/activity-log/${teamId}`,
441
+ params
442
+ );
443
+ const items = Array.isArray(response.data) ? response.data.map(shapeActivity) : [];
444
+ const data = { items };
445
+ if (response.meta !== void 0) data.meta = shape(response.meta);
446
+ return respond({
447
+ summary: items.length === 0 ? "No activity log entries match the given filters." : `Returned ${items.length} activity log entr${items.length === 1 ? "y" : "ies"}.`,
448
+ data
449
+ });
450
+ }
451
+ });
452
+
453
+ // src/tools/cron.ts
454
+ import { z as z3 } from "zod";
455
+ defineTool({
456
+ name: "list_cron_executions",
457
+ category: "cron",
458
+ description: [
459
+ "List recent execution records for a cron service (newest first).",
460
+ "",
461
+ 'When to use: investigating whether a scheduled job ran on time, finding the most recent failure, or auditing cron run-times. Only works on services of type "cron"; for web services this returns an error.',
462
+ "",
463
+ "Inputs:",
464
+ " - service_id: publicId of the cron service.",
465
+ " - limit (optional): how many executions to fetch (default 50, max enforced server-side).",
466
+ "",
467
+ "Returns: { items: CronExecution[] } \u2014 id, publicId, status (succeeded|failed|running|queued), startedAt, finishedAt, exitCode, triggeredBy.",
468
+ "",
469
+ 'Example: list_cron_executions({ service_id: "svc_cron" }) \u2192 { items: [{ status: "succeeded", exitCode: 0, \u2026 }, \u2026] }'
470
+ ].join("\n"),
471
+ input: {
472
+ service_id: z3.string().describe("Cron service publicId."),
473
+ limit: z3.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
474
+ },
475
+ handler: async (args, ctx) => {
476
+ const teamId = await ctx.resolveTeamId();
477
+ const opts = args.limit !== void 0 ? { limit: args.limit } : void 0;
478
+ const response = await ctx.hoststack.cron.list(teamId, args.service_id, opts);
479
+ const data = shapeList(response, "executions", shapeCronExecution);
480
+ const summary = data.items.length === 0 ? `No cron executions yet for ${args.service_id}.` : `Found ${data.items.length} cron execution${data.items.length === 1 ? "" : "s"} for ${args.service_id}.`;
481
+ return respond({ summary, data });
482
+ }
483
+ });
484
+ defineTool({
485
+ name: "get_cron_execution",
486
+ category: "cron",
487
+ description: [
488
+ "Fetch a single cron execution record. Includes status, exit code, and timing.",
489
+ "",
490
+ "When to use: drilling into a specific run after list_cron_executions surfaced a failure, or correlating an execution timestamp with logs.",
491
+ "",
492
+ "Inputs:",
493
+ " - service_id: publicId of the cron service.",
494
+ " - execution_id: publicId of the execution record.",
495
+ "",
496
+ "Returns: { execution: CronExecution }.",
497
+ "",
498
+ 'Example: get_cron_execution({ service_id: "svc_cron", execution_id: "exe_xyz" }) \u2192 { execution: { status: "failed", exitCode: 1, \u2026 } }'
499
+ ].join("\n"),
500
+ input: {
501
+ service_id: z3.string().describe("Cron service publicId."),
502
+ execution_id: z3.string().describe("Execution publicId.")
503
+ },
504
+ handler: async (args, ctx) => {
505
+ const teamId = await ctx.resolveTeamId();
506
+ const response = await ctx.hoststack.cron.get(teamId, args.service_id, args.execution_id);
507
+ const data = { execution: shapeCronExecution(response.execution) };
508
+ const status = data.execution && "status" in data.execution ? data.execution.status : "unknown";
509
+ return respond({ summary: `Execution ${args.execution_id} ${status}.`, data });
510
+ }
511
+ });
512
+
513
+ // src/tools/databases.ts
514
+ import { z as z4 } from "zod";
515
+ defineTool({
516
+ name: "list_databases",
517
+ category: "databases",
518
+ description: [
519
+ "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB, Meilisearch, NATS) inside a project.",
520
+ "",
521
+ "When to use: an agent needs to know what data stores exist in a project before connecting a service or running a migration. Pair with list_projects to discover project IDs.",
522
+ "",
523
+ "Inputs:",
524
+ " - project_id: numeric project ID (from list_projects).",
525
+ "",
526
+ "Returns: { items: Database[] } \u2014 id, publicId, name, type, status, version, projectId, createdAt.",
527
+ "",
528
+ 'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
529
+ ].join("\n"),
530
+ input: {
531
+ project_id: z4.number().int().positive().describe("Numeric project ID (from list_projects).")
532
+ },
533
+ handler: async (args, ctx) => {
534
+ const teamId = await ctx.resolveTeamId();
535
+ const response = await ctx.hoststack.databases.list(teamId, args.project_id);
536
+ const data = shapeList(response, "databases", shapeDatabase);
537
+ const summary = data.items.length === 0 ? `No databases in project ${args.project_id}.` : `Found ${data.items.length} database${data.items.length === 1 ? "" : "s"} in project ${args.project_id}.`;
538
+ return respond({ summary, data });
539
+ }
540
+ });
541
+ defineTool({
542
+ name: "get_database",
543
+ category: "databases",
544
+ description: [
545
+ "Fetch a single managed database by ID. Includes status, type, version, and project link.",
546
+ "",
547
+ "When to use: confirming the version of a database before generating connection strings, or checking the status of a recently created database. To retrieve the actual connection credentials, use the dashboard \u2014 credentials are intentionally not exposed via this MCP tool.",
548
+ "",
549
+ "Inputs:",
550
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
551
+ "",
552
+ "Returns: { database: Database }.",
553
+ "",
554
+ 'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
555
+ ].join("\n"),
556
+ input: {
557
+ database_id: z4.string().describe("Database publicId (e.g. db_xyz).")
558
+ },
559
+ handler: async (args, ctx) => {
560
+ const teamId = await ctx.resolveTeamId();
561
+ const response = await ctx.hoststack.databases.get(teamId, args.database_id);
562
+ const data = { database: shapeDatabase(response.database) };
563
+ const type = data.database && "type" in data.database ? data.database.type : "unknown";
564
+ const status = data.database && "status" in data.database ? data.database.status : "unknown";
565
+ return respond({ summary: `Database ${args.database_id} (${type}) is ${status}.`, data });
566
+ }
567
+ });
568
+
569
+ // src/tools/deploys.ts
570
+ import { z as z5 } from "zod";
571
+ defineTool({
572
+ name: "list_deploys",
573
+ category: "deploys",
574
+ description: [
575
+ "List deploys for a service in reverse-chronological order (newest first).",
576
+ "",
577
+ "When to use: investigating a recent deploy outcome, finding the last successful deploy ID, comparing run-times, or just checking deploy history before triggering a new one.",
578
+ "",
579
+ "Inputs:",
580
+ ' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
581
+ "",
582
+ "Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, durationMs.",
583
+ "",
584
+ 'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
585
+ ].join("\n"),
586
+ input: {
587
+ service_id: z5.string().describe("Service publicId (e.g. svc_abc123).")
588
+ },
589
+ handler: async (args, ctx) => {
590
+ const teamId = await ctx.resolveTeamId();
591
+ const response = await ctx.hoststack.deploys.list(teamId, args.service_id);
592
+ const data = shapeList(response, "deploys", shapeDeploy);
593
+ const summary = data.items.length === 0 ? `No deploys yet for service ${args.service_id}.` : `Found ${data.items.length} deploy${data.items.length === 1 ? "" : "s"} for service ${args.service_id}.`;
594
+ return respond({ summary, data });
595
+ }
596
+ });
597
+ defineTool({
598
+ name: "get_deploy",
599
+ category: "deploys",
600
+ description: [
601
+ "Fetch a single deploy by ID, including its current status, commit metadata, and timestamps.",
602
+ "",
603
+ "When to use: drilling into the details of a specific deploy after list_deploys, polling a build's status, or grabbing the commit SHA so you can correlate logs with code.",
604
+ "",
605
+ "Inputs:",
606
+ " - service_id: publicId of the service.",
607
+ ' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
608
+ "",
609
+ "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, durationMs, finishedAt, etc).",
610
+ "",
611
+ 'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
612
+ ].join("\n"),
613
+ input: {
614
+ service_id: z5.string().describe("Service publicId."),
615
+ deploy_id: z5.string().describe("Deploy publicId.")
616
+ },
617
+ handler: async (args, ctx) => {
618
+ const teamId = await ctx.resolveTeamId();
619
+ const response = await ctx.hoststack.deploys.get(teamId, args.service_id, args.deploy_id);
620
+ const data = { deploy: shapeDeploy(response.deploy) };
621
+ const status = data.deploy && typeof data.deploy === "object" && "status" in data.deploy ? data.deploy.status : "unknown";
622
+ return respond({ summary: `Deploy ${args.deploy_id} is ${status}.`, data });
623
+ }
624
+ });
625
+ defineTool({
626
+ name: "trigger_deploy",
627
+ category: "deploys",
628
+ description: [
629
+ "Kick off a new deploy from the latest commit on the service branch. Optionally clear the build cache for a clean rebuild.",
630
+ "",
631
+ 'When to use: the user explicitly asks to deploy ("ship it", "redeploy", "kick off a new build"). For services with auto-deploy enabled, a fresh git push already triggers a deploy automatically \u2014 only call this for manual triggers, cache-clearing, or after a config change.',
632
+ "",
633
+ "Inputs:",
634
+ " - service_id: publicId of the service.",
635
+ " - clear_cache (optional): boolean \u2014 discard the cached build layers. Default false.",
636
+ "",
637
+ 'Returns: { deploy: Deploy } \u2014 the new deploy record. Status will start as "pending" then transition through "building" \u2192 "deploying" \u2192 "live" or "failed".',
638
+ "",
639
+ 'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
640
+ ].join("\n"),
641
+ input: {
642
+ service_id: z5.string().describe("Service publicId."),
643
+ clear_cache: z5.boolean().optional().describe("Clear the build cache (default false).")
644
+ },
645
+ handler: async (args, ctx) => {
646
+ const teamId = await ctx.resolveTeamId();
647
+ const input = {};
648
+ if (args.clear_cache !== void 0) input.clearCache = args.clear_cache;
649
+ const response = await ctx.hoststack.deploys.trigger(teamId, args.service_id, input);
650
+ const data = { deploy: shapeDeploy(response.deploy) };
651
+ const publicId = data.deploy && "publicId" in data.deploy ? data.deploy.publicId : "unknown";
652
+ return respond({
653
+ summary: `Triggered deploy ${publicId} on service ${args.service_id}.`,
654
+ data
655
+ });
656
+ }
657
+ });
658
+ defineTool({
659
+ name: "cancel_deploy",
660
+ category: "deploys",
661
+ description: [
662
+ "Cancel a running deploy. Stops the build/deploy pipeline mid-flight; the previously live revision keeps serving traffic.",
663
+ "",
664
+ "When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Has no effect on already-finished deploys.",
665
+ "",
666
+ "Inputs:",
667
+ " - service_id: publicId of the service.",
668
+ " - deploy_id: publicId of the deploy to cancel.",
669
+ "",
670
+ "Returns: { ok: true }.",
671
+ "",
672
+ 'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
673
+ ].join("\n"),
674
+ input: {
675
+ service_id: z5.string().describe("Service publicId."),
676
+ deploy_id: z5.string().describe("Deploy publicId.")
677
+ },
678
+ handler: async (args, ctx) => {
679
+ const teamId = await ctx.resolveTeamId();
680
+ await ctx.hoststack.deploys.cancel(teamId, args.service_id, args.deploy_id);
681
+ return respond({
682
+ summary: `Cancelled deploy ${args.deploy_id} on service ${args.service_id}.`,
683
+ data: { ok: true }
684
+ });
685
+ }
686
+ });
687
+ defineTool({
688
+ name: "get_deploy_logs",
689
+ category: "deploys",
690
+ description: [
691
+ "Fetch the build/deploy logs for a single deploy. Returns the entire log buffer the agent can the search for errors.",
692
+ "",
693
+ "When to use: a deploy failed and you need to read the build output to diagnose. Pair with get_deploy to find a failed deploy_id, then call this. For runtime (post-deploy) logs of the running container, use get_service_logs instead.",
694
+ "",
695
+ "Inputs:",
696
+ " - service_id: publicId of the service.",
697
+ " - deploy_id: publicId of the deploy.",
698
+ "",
699
+ "Returns: { logs: string } \u2014 full build output as a single string. May be truncated by the API if the deploy is still in progress.",
700
+ "",
701
+ 'Example: get_deploy_logs({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { logs: "Building\u2026\\nStep 1/8\u2026\\n\u2026" }'
702
+ ].join("\n"),
703
+ input: {
704
+ service_id: z5.string().describe("Service publicId."),
705
+ deploy_id: z5.string().describe("Deploy publicId.")
706
+ },
707
+ handler: async (args, ctx) => {
708
+ const teamId = await ctx.resolveTeamId();
709
+ const response = await ctx.hoststack.deploys.getLogs(teamId, args.service_id, args.deploy_id);
710
+ const logs = typeof response.logs === "string" ? response.logs : "";
711
+ const lines = logs ? logs.split("\n").length : 0;
712
+ return respond({
713
+ summary: `Fetched ${lines} log line${lines === 1 ? "" : "s"} for deploy ${args.deploy_id}.`,
714
+ data: { logs }
715
+ });
716
+ }
717
+ });
718
+
719
+ // src/tools/domains.ts
720
+ import { z as z6 } from "zod";
721
+ defineTool({
722
+ name: "list_domains",
723
+ category: "domains",
724
+ description: [
725
+ "List every custom domain attached to the team's services.",
726
+ "",
727
+ "When to use: enumerate live domains, audit DNS verification status, or find which service a hostname resolves to before troubleshooting routing.",
728
+ "",
729
+ "Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, createdAt.",
730
+ "",
731
+ 'Example: list_domains() \u2192 { items: [{ hostname: "api.example.com", verified: true, sslStatus: "active", \u2026 }] }'
732
+ ].join("\n"),
733
+ input: {},
734
+ handler: async (_args, ctx) => {
735
+ const teamId = await ctx.resolveTeamId();
736
+ const response = await ctx.hoststack.domains.list(teamId);
737
+ const data = shapeList(response, "domains", shapeDomain);
738
+ const summary = data.items.length === 0 ? "No custom domains configured." : `Found ${data.items.length} custom domain${data.items.length === 1 ? "" : "s"}.`;
739
+ return respond({ summary, data });
740
+ }
741
+ });
742
+ defineTool({
743
+ name: "add_domain",
744
+ category: "domains",
745
+ description: [
746
+ "Attach a custom hostname to the team \u2014 optionally pinned to a specific service. After adding, point your DNS at the targets returned and call verify_domain.",
747
+ "",
748
+ "When to use: the user wants to put a custom domain in front of a HostStack service (e.g. point api.example.com at svc_abc). The hostname must be unique across the team.",
749
+ "",
750
+ "Inputs:",
751
+ ' - hostname: the domain to add (e.g. "api.example.com").',
752
+ " - service_id (optional): publicId of the service to bind to. If omitted, the domain is added unbound and you can attach it later with update_domain.",
753
+ "",
754
+ "Returns: { domain: Domain } \u2014 includes dnsTargets you must configure (CNAME / A records).",
755
+ "",
756
+ 'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
757
+ ].join("\n"),
758
+ input: {
759
+ hostname: z6.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
760
+ service_id: z6.string().optional().describe("Optional service publicId to bind to.")
761
+ },
762
+ handler: async (args, ctx) => {
763
+ const teamId = await ctx.resolveTeamId();
764
+ const input = { domain: args.hostname };
765
+ if (args.service_id !== void 0) input.serviceId = args.service_id;
766
+ const response = await ctx.hoststack.domains.add(teamId, input);
767
+ const data = { domain: shapeDomain(response.domain) };
768
+ return respond({
769
+ summary: `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
770
+ data
771
+ });
772
+ }
773
+ });
774
+ defineTool({
775
+ name: "verify_domain",
776
+ category: "domains",
777
+ description: [
778
+ "Trigger DNS verification for a previously-added domain. HostStack checks that the dnsTargets returned by add_domain are configured at the registrar.",
779
+ "",
780
+ "When to use: the user has set up DNS records and is ready to flip the domain live. Returns success when verification passes; the domain stays in pending state if DNS is still propagating.",
781
+ "",
782
+ "Inputs:",
783
+ " - domain_id: publicId of the domain (from list_domains or add_domain).",
784
+ "",
785
+ "Returns: { ok: true }. Re-call list_domains to inspect the updated verified flag and SSL status.",
786
+ "",
787
+ 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
788
+ ].join("\n"),
789
+ input: {
790
+ domain_id: z6.string().describe("Domain publicId.")
791
+ },
792
+ handler: async (args, ctx) => {
793
+ const teamId = await ctx.resolveTeamId();
794
+ await ctx.hoststack.domains.verify(teamId, args.domain_id);
795
+ return respond({ summary: `Triggered DNS verification for ${args.domain_id}.`, data: { ok: true } });
796
+ }
797
+ });
798
+ defineTool({
799
+ name: "remove_domain",
800
+ category: "domains",
801
+ description: [
802
+ "Detach a custom hostname from the team. DESTRUCTIVE: the domain stops routing immediately and any pinned service must fall back to its default hostname.",
803
+ "",
804
+ "When to use: the user wants to retire a custom domain. Confirm with the user first \u2014 the change is immediate and cannot be undone except by re-adding the domain and re-verifying DNS.",
805
+ "",
806
+ "Inputs:",
807
+ " - domain_id: publicId of the domain to remove.",
808
+ "",
809
+ "Returns: { ok: true }.",
810
+ "",
811
+ 'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
812
+ ].join("\n"),
813
+ input: {
814
+ domain_id: z6.string().describe("Domain publicId.")
815
+ },
816
+ handler: async (args, ctx) => {
817
+ const teamId = await ctx.resolveTeamId();
818
+ await ctx.hoststack.domains.remove(teamId, args.domain_id);
819
+ return respond({ summary: `Removed domain ${args.domain_id}.`, data: { ok: true } });
820
+ }
821
+ });
822
+
823
+ // src/tools/env-vars.ts
824
+ import { z as z7 } from "zod";
825
+ defineTool({
826
+ name: "list_env_vars",
827
+ category: "env-vars",
828
+ description: [
829
+ 'List environment variables for a service. Secret values are masked server-side ("\u2022\u2022\u2022\u2022\u2022\u2022"); only non-secret values come through in the clear.',
830
+ "",
831
+ "When to use: auditing what configuration a service has, finding the key for a value the user mentions by name, or confirming a variable was set after a write. Never assume the value field is plaintext for secret rows \u2014 it is masked.",
832
+ "",
833
+ "Inputs:",
834
+ " - service_id: publicId of the service.",
835
+ "",
836
+ "Returns: { items: EnvVar[] } \u2014 id, publicId, key, value (masked if isSecret), isSecret, target, linkedDatabaseId, createdAt.",
837
+ "",
838
+ 'Example: list_env_vars({ service_id: "svc_abc" }) \u2192 { items: [{ key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", isSecret: true }, { key: "PORT", value: "3000", isSecret: false }] }'
839
+ ].join("\n"),
840
+ input: {
841
+ service_id: z7.string().describe("Service publicId.")
842
+ },
843
+ handler: async (args, ctx) => {
844
+ const teamId = await ctx.resolveTeamId();
845
+ const response = await ctx.hoststack.envVars.list(teamId, args.service_id);
846
+ const data = shapeList(response, "envVars", shapeEnvVar);
847
+ const summary = data.items.length === 0 ? `No env vars set on service ${args.service_id}.` : `Found ${data.items.length} env var${data.items.length === 1 ? "" : "s"} on service ${args.service_id}.`;
848
+ return respond({ summary, data });
849
+ }
850
+ });
851
+ defineTool({
852
+ name: "set_env_var",
853
+ category: "env-vars",
854
+ description: [
855
+ "Upsert a single environment variable on a service: creates the key if missing, updates the value if it already exists. Match is by key (case-sensitive).",
856
+ "",
857
+ "When to use: rotate a secret, add a new config knob, or change a value the user dictated. The MCP layer looks up the existing var by key first; you do not need to know the env-var ID.",
858
+ "",
859
+ "Inputs:",
860
+ " - service_id: publicId of the service.",
861
+ ' - key: env-var name (e.g. "DATABASE_URL").',
862
+ " - value: new value (will be encrypted at rest if is_secret=true).",
863
+ " - is_secret (optional): true marks the value as secret (masked on read). Default true for safety; pass false for boring config like PORT or NODE_ENV.",
864
+ "",
865
+ 'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
866
+ "",
867
+ 'Example: set_env_var({ service_id: "svc_abc", key: "DATABASE_URL", value: "postgres://\u2026", is_secret: true }) \u2192 { envVar: { key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", \u2026 }, action: "updated" }'
868
+ ].join("\n"),
869
+ input: {
870
+ service_id: z7.string().describe("Service publicId."),
871
+ key: z7.string().min(1).max(128).describe("Env-var key."),
872
+ value: z7.string().describe("New value."),
873
+ is_secret: z7.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
874
+ },
875
+ handler: async (args, ctx) => {
876
+ const teamId = await ctx.resolveTeamId();
877
+ const isSecret = args.is_secret ?? true;
878
+ const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
879
+ const match = existing.envVars.find((v) => v.key === args.key);
880
+ if (match) {
881
+ const response2 = await ctx.hoststack.envVars.update(teamId, args.service_id, String(match.id), {
882
+ value: args.value,
883
+ isSecret
884
+ });
885
+ const data2 = {
886
+ envVar: shapeEnvVar(response2.envVar),
887
+ action: "updated"
888
+ };
889
+ return respond({ summary: `Updated ${args.key} on service ${args.service_id}.`, data: data2 });
890
+ }
891
+ const response = await ctx.hoststack.envVars.create(teamId, args.service_id, {
892
+ key: args.key,
893
+ value: args.value,
894
+ isSecret
895
+ });
896
+ const data = {
897
+ envVar: shapeEnvVar(response.envVar),
898
+ action: "created"
899
+ };
900
+ return respond({ summary: `Created ${args.key} on service ${args.service_id}.`, data });
901
+ }
902
+ });
903
+ defineTool({
904
+ name: "delete_env_var",
905
+ category: "env-vars",
906
+ description: [
907
+ "Remove an environment variable from a service by key (case-sensitive).",
908
+ "",
909
+ "When to use: cleaning up unused config, or rotating away from a leaked secret. The MCP looks up the env-var ID from the key automatically.",
910
+ "",
911
+ "Inputs:",
912
+ " - service_id: publicId of the service.",
913
+ " - key: env-var name to delete.",
914
+ "",
915
+ "Returns: { ok: true } on success. Returns an error if the key was not found.",
916
+ "",
917
+ 'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
918
+ ].join("\n"),
919
+ input: {
920
+ service_id: z7.string().describe("Service publicId."),
921
+ key: z7.string().min(1).max(128).describe("Env-var key to delete.")
922
+ },
923
+ handler: async (args, ctx) => {
924
+ const teamId = await ctx.resolveTeamId();
925
+ const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
926
+ const match = existing.envVars.find((v) => v.key === args.key);
927
+ if (!match) {
928
+ return respondError(
929
+ `Env var "${args.key}" not found on service ${args.service_id}.`,
930
+ { key: args.key, service_id: args.service_id }
931
+ );
932
+ }
933
+ await ctx.hoststack.envVars.delete(teamId, args.service_id, String(match.id));
934
+ return respond({
935
+ summary: `Deleted ${args.key} from service ${args.service_id}.`,
936
+ data: { ok: true }
937
+ });
938
+ }
939
+ });
940
+ defineTool({
941
+ name: "bulk_set_env_vars",
942
+ category: "env-vars",
943
+ description: [
944
+ "Replace the entire env-var set on a service with the given list. Equivalent to deleting all current vars and creating the supplied ones \u2014 use with care.",
945
+ "",
946
+ "When to use: importing a .env file, mirroring config from a sibling service, or doing a clean reset. For incremental changes, use set_env_var per key \u2014 bulk_set is destructive for any key not in the supplied list.",
947
+ "",
948
+ "Inputs:",
949
+ " - service_id: publicId of the service.",
950
+ " - env_vars: array of { key, value, is_secret? }. is_secret defaults to true per row.",
951
+ "",
952
+ "Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
953
+ "",
954
+ 'Example: bulk_set_env_vars({ service_id: "svc_abc", env_vars: [{ key: "PORT", value: "3000", is_secret: false }, { key: "DATABASE_URL", value: "\u2026", is_secret: true }] }) \u2192 { ok: true }'
955
+ ].join("\n"),
956
+ input: {
957
+ service_id: z7.string().describe("Service publicId."),
958
+ env_vars: z7.array(
959
+ z7.object({
960
+ key: z7.string().min(1).max(128),
961
+ value: z7.string(),
962
+ is_secret: z7.boolean().optional()
963
+ })
964
+ ).max(500).describe("Array of env-var rows. Hard cap 500.")
965
+ },
966
+ handler: async (args, ctx) => {
967
+ const teamId = await ctx.resolveTeamId();
968
+ const payload = {
969
+ envVars: args.env_vars.map((v) => {
970
+ const row = {
971
+ key: v.key,
972
+ value: v.value
973
+ };
974
+ if (v.is_secret !== void 0) row.isSecret = v.is_secret;
975
+ return row;
976
+ })
977
+ };
978
+ await ctx.hoststack.envVars.bulkSet(teamId, args.service_id, payload);
979
+ return respond({
980
+ summary: `Replaced env-var set on service ${args.service_id} with ${args.env_vars.length} entr${args.env_vars.length === 1 ? "y" : "ies"}.`,
981
+ data: { ok: true, count: args.env_vars.length }
982
+ });
983
+ }
984
+ });
985
+
986
+ // src/tools/meta.ts
987
+ defineTool({
988
+ name: "get_me",
989
+ category: "meta",
990
+ description: [
991
+ "Return the user and team identity bound to the current API key. Useful at the start of a conversation to confirm which account the agent is operating on, especially when a user has multiple HostStack environments.",
992
+ "",
993
+ "When to use: orient yourself at the start of a session, confirm which team the API key targets, or surface the email/team-name in a reply to the user.",
994
+ "",
995
+ "Returns: { user: { id, email, name, ... }, team?: { id, publicId, name, plan, ... } }.",
996
+ "",
997
+ 'Example: get_me() \u2192 { user: { id: 1, email: "ada@\u2026", \u2026 }, team: { id: 7, name: "acme", plan: "pro" } }'
998
+ ].join("\n"),
999
+ input: {},
1000
+ handler: async (_args, ctx) => {
1001
+ const me = await ctx.api.get("/api/auth/me");
1002
+ const data = {
1003
+ user: shapeUser(me.user)
1004
+ };
1005
+ if (me.team) data.team = shapeTeam(me.team);
1006
+ const teamLabel = me.team && typeof me.team === "object" && "name" in me.team ? ` on team ${me.team.name}` : "";
1007
+ const userEmail = me.user && typeof me.user === "object" && "email" in me.user ? me.user.email : "unknown";
1008
+ return respond({ summary: `Authenticated as ${userEmail}${teamLabel}.`, data });
1009
+ }
1010
+ });
1011
+
1012
+ // src/tools/projects.ts
1013
+ import { z as z8 } from "zod";
1014
+ defineTool({
1015
+ name: "list_projects",
1016
+ category: "projects",
1017
+ description: [
1018
+ "List every project in the active HostStack team.",
1019
+ "",
1020
+ "When to use: the agent needs an overview of what projects exist before drilling into services, deploys, or databases. Use this as the first step when the user mentions a project by name and you need to resolve its ID.",
1021
+ "",
1022
+ "Returns: { items: Project[] } \u2014 each project includes id, publicId, name, description, createdAt.",
1023
+ "",
1024
+ 'Example: list_projects() \u2192 { items: [{ id: 12, publicId: "prj_\u2026", name: "billing-api", \u2026 }] }'
1025
+ ].join("\n"),
1026
+ input: {},
1027
+ handler: async (_args, ctx) => {
1028
+ const teamId = await ctx.resolveTeamId();
1029
+ const response = await ctx.hoststack.projects.list(teamId);
1030
+ const data = shapeList(response, "projects", shapeProject);
1031
+ const summary = data.items.length === 0 ? "No projects yet \u2014 create one in the dashboard or via create_project." : `Found ${data.items.length} project${data.items.length === 1 ? "" : "s"}.`;
1032
+ return respond({ summary, data });
1033
+ }
1034
+ });
1035
+ defineTool({
1036
+ name: "create_project",
1037
+ category: "projects",
1038
+ description: [
1039
+ "Create a new project (logical grouping of services + databases).",
1040
+ "",
1041
+ "When to use: the user wants to set up a new app or environment in HostStack. Projects are a free organisational layer \u2014 they don't cost anything until you add services or databases inside them.",
1042
+ "",
1043
+ "Inputs:",
1044
+ " - name: human-readable project name (1\u201360 chars).",
1045
+ " - description (optional): short blurb shown in the dashboard.",
1046
+ ' - region (optional): "fsn1" (Falkenstein) | "nbg1" (Nuremberg) | "hel1" (Helsinki). Default depends on team plan.',
1047
+ "",
1048
+ "Returns: { project: Project } \u2014 includes the new id and publicId.",
1049
+ "",
1050
+ 'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
1051
+ ].join("\n"),
1052
+ input: {
1053
+ name: z8.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
1054
+ description: z8.string().max(500).optional().describe("Short description (\u2264500 chars)."),
1055
+ region: z8.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
1056
+ },
1057
+ handler: async (args, ctx) => {
1058
+ const teamId = await ctx.resolveTeamId();
1059
+ const input = { name: args.name };
1060
+ if (args.description !== void 0) input.description = args.description;
1061
+ if (args.region !== void 0) input.region = args.region;
1062
+ const response = await ctx.hoststack.projects.create(teamId, input);
1063
+ const data = { project: shapeProject(response.project) };
1064
+ const publicId = data.project && "publicId" in data.project ? data.project.publicId : "unknown";
1065
+ return respond({ summary: `Created project "${args.name}" (${publicId}).`, data });
1066
+ }
1067
+ });
1068
+ defineTool({
1069
+ name: "update_project",
1070
+ category: "projects",
1071
+ description: [
1072
+ "Rename a project or update its description.",
1073
+ "",
1074
+ "When to use: the user wants to fix a typo in a project name, attach a clearer description, or align the dashboard label with their internal naming.",
1075
+ "",
1076
+ "Inputs (all optional, at least one required):",
1077
+ " - project_id: publicId of the project (required).",
1078
+ " - name: new name (1\u201360 chars).",
1079
+ " - description: new description (\u2264500 chars).",
1080
+ "",
1081
+ "Returns: { project: Project } \u2014 the updated record.",
1082
+ "",
1083
+ 'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
1084
+ ].join("\n"),
1085
+ input: {
1086
+ project_id: z8.string().describe("Project publicId."),
1087
+ name: z8.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
1088
+ description: z8.string().max(500).optional().describe("New description (\u2264500 chars).")
1089
+ },
1090
+ handler: async (args, ctx) => {
1091
+ if (args.name === void 0 && args.description === void 0) {
1092
+ return respond({
1093
+ summary: "No fields to update \u2014 pass `name` and/or `description`.",
1094
+ data: {}
1095
+ });
1096
+ }
1097
+ const teamId = await ctx.resolveTeamId();
1098
+ const input = {};
1099
+ if (args.name !== void 0) input.name = args.name;
1100
+ if (args.description !== void 0) input.description = args.description;
1101
+ const response = await ctx.hoststack.projects.update(teamId, args.project_id, input);
1102
+ const data = { project: shapeProject(response.project) };
1103
+ return respond({ summary: `Updated project ${args.project_id}.`, data });
1104
+ }
1105
+ });
1106
+ defineTool({
1107
+ name: "get_project",
1108
+ category: "projects",
1109
+ description: [
1110
+ "Fetch a single project by ID. Includes name, description, and timestamps.",
1111
+ "",
1112
+ "When to use: confirming a project exists before creating resources inside it, or pulling the canonical name/description for a reply to the user.",
1113
+ "",
1114
+ "Inputs:",
1115
+ ' - project_id: publicId of the project (e.g. "prj_abc123").',
1116
+ "",
1117
+ "Returns: { project: Project }.",
1118
+ "",
1119
+ 'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
1120
+ ].join("\n"),
1121
+ input: {
1122
+ project_id: z8.string().describe("Project publicId (e.g. prj_abc123).")
1123
+ },
1124
+ handler: async (args, ctx) => {
1125
+ const teamId = await ctx.resolveTeamId();
1126
+ const response = await ctx.hoststack.projects.get(teamId, args.project_id);
1127
+ const data = { project: shapeProject(response.project) };
1128
+ const name = data.project && "name" in data.project ? data.project.name : args.project_id;
1129
+ return respond({ summary: `Project "${name}".`, data });
1130
+ }
1131
+ });
1132
+
1133
+ // src/tools/services.ts
1134
+ import { z as z9 } from "zod";
1135
+ defineTool({
1136
+ name: "list_services",
1137
+ category: "services",
1138
+ description: [
1139
+ "List every service (web, worker, cron, private) in the active HostStack team.",
1140
+ "",
1141
+ "When to use: the agent needs to find a service by name, check what is deployed, or pick a target for a follow-up tool (logs, deploys, env vars). This is the canonical way to resolve a publicId from a human-friendly name.",
1142
+ "",
1143
+ "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
1144
+ "",
1145
+ 'Example: list_services() \u2192 { items: [{ id: 31, publicId: "svc_\u2026", name: "api", type: "web", status: "running", \u2026 }] }'
1146
+ ].join("\n"),
1147
+ input: {},
1148
+ handler: async (_args, ctx) => {
1149
+ const teamId = await ctx.resolveTeamId();
1150
+ const response = await ctx.hoststack.services.list(teamId);
1151
+ const data = shapeList(response, "services", shapeService);
1152
+ const summary = data.items.length === 0 ? "No services yet \u2014 create one with create_service or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}.`;
1153
+ return respond({ summary, data });
1154
+ }
1155
+ });
1156
+ defineTool({
1157
+ name: "get_service",
1158
+ category: "services",
1159
+ description: [
1160
+ "Fetch a single service by ID, including its current status and configuration summary.",
1161
+ "",
1162
+ "When to use: drilling into a service after list_services, checking deploy/runtime status, or grabbing the repo+branch before triggering a deploy.",
1163
+ "",
1164
+ "Inputs:",
1165
+ ' - service_id: publicId of the service (e.g. "svc_abc123").',
1166
+ "",
1167
+ "Returns: { service: Service } \u2014 type, status, runtime, repoUrl, branch, autoDeploy, region, plan, createdAt, updatedAt.",
1168
+ "",
1169
+ 'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
1170
+ ].join("\n"),
1171
+ input: {
1172
+ service_id: z9.string().describe("Service publicId (e.g. svc_abc123).")
1173
+ },
1174
+ handler: async (args, ctx) => {
1175
+ const teamId = await ctx.resolveTeamId();
1176
+ const response = await ctx.hoststack.services.get(teamId, args.service_id);
1177
+ const data = { service: shapeService(response.service) };
1178
+ const status = data.service && "status" in data.service ? data.service.status : "unknown";
1179
+ return respond({ summary: `Service ${args.service_id} is ${status}.`, data });
1180
+ }
1181
+ });
1182
+ defineTool({
1183
+ name: "get_service_metrics",
1184
+ category: "services",
1185
+ description: [
1186
+ "Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
1187
+ "",
1188
+ "When to use: a service is reportedly slow, you suspect resource pressure, or you want a quick health snapshot before scaling. Returns a single point-in-time sample, not a time series.",
1189
+ "",
1190
+ "Inputs:",
1191
+ " - service_id: publicId of the service.",
1192
+ "",
1193
+ "Returns: { metrics: { cpu, memory, network, requests } } \u2014 values are normalised utilisation/throughput numbers.",
1194
+ "",
1195
+ 'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
1196
+ ].join("\n"),
1197
+ input: {
1198
+ service_id: z9.string().describe("Service publicId.")
1199
+ },
1200
+ handler: async (args, ctx) => {
1201
+ const teamId = await ctx.resolveTeamId();
1202
+ const response = await ctx.hoststack.services.getMetrics(teamId, args.service_id);
1203
+ const data = { metrics: shape(response.metrics) };
1204
+ return respond({ summary: `Metrics snapshot for service ${args.service_id}.`, data });
1205
+ }
1206
+ });
1207
+ defineTool({
1208
+ name: "update_service",
1209
+ category: "services",
1210
+ description: [
1211
+ "Rename a service. Only the human-readable name changes \u2014 the publicId is permanent.",
1212
+ "",
1213
+ "When to use: the user wants to relabel a service in the dashboard. For deeper config edits (build command, branch, scale, plan), use update_service_config.",
1214
+ "",
1215
+ "Inputs:",
1216
+ " - service_id: publicId of the service.",
1217
+ " - name: new name (1\u201360 chars).",
1218
+ "",
1219
+ "Returns: { service: Service }.",
1220
+ "",
1221
+ 'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
1222
+ ].join("\n"),
1223
+ input: {
1224
+ service_id: z9.string().describe("Service publicId."),
1225
+ name: z9.string().min(1).max(60).describe("New service name (1\u201360 chars).")
1226
+ },
1227
+ handler: async (args, ctx) => {
1228
+ const teamId = await ctx.resolveTeamId();
1229
+ const response = await ctx.hoststack.services.update(teamId, args.service_id, {
1230
+ name: args.name
1231
+ });
1232
+ const data = { service: shapeService(response.service) };
1233
+ return respond({ summary: `Renamed service ${args.service_id} to "${args.name}".`, data });
1234
+ }
1235
+ });
1236
+ defineTool({
1237
+ name: "update_service_config",
1238
+ category: "services",
1239
+ description: [
1240
+ "Update build/runtime configuration for a service: build command, start command, branch, root directory, dockerfile path, auto-deploy flag, instance count, plan tier. All fields optional \u2014 pass only what you want to change.",
1241
+ "",
1242
+ "When to use: the user wants to tweak how a service builds or runs without redeploying manually. Updating any field marked dispatch-eligible (branch, build/start command, plan, root, dockerfile) typically triggers a follow-up deploy automatically; instance count scales without redeploying.",
1243
+ "",
1244
+ "Inputs:",
1245
+ " - service_id: publicId of the service.",
1246
+ " - build_command, start_command (optional): shell commands.",
1247
+ " - branch (optional): git branch to track.",
1248
+ " - root_directory, dockerfile_path (optional): build context overrides.",
1249
+ " - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
1250
+ " - instance_count (optional): integer \u22651 \u2014 manual scale.",
1251
+ ' - plan (optional): plan tier slug (e.g. "starter", "standard", "pro").',
1252
+ "",
1253
+ "Returns: { config: ServiceConfig } \u2014 the updated config record.",
1254
+ "",
1255
+ 'Example: update_service_config({ service_id: "svc_abc", instance_count: 3 }) \u2192 { config: { instanceCount: 3, \u2026 } }'
1256
+ ].join("\n"),
1257
+ input: {
1258
+ service_id: z9.string().describe("Service publicId."),
1259
+ build_command: z9.string().optional().describe("Build shell command."),
1260
+ start_command: z9.string().optional().describe("Start shell command."),
1261
+ branch: z9.string().optional().describe("Git branch to track."),
1262
+ root_directory: z9.string().optional().describe("Build context root."),
1263
+ dockerfile_path: z9.string().optional().describe("Path to Dockerfile relative to root."),
1264
+ auto_deploy: z9.boolean().optional().describe("Auto-deploy on push."),
1265
+ instance_count: z9.number().int().positive().optional().describe("Manual scale (\u22651)."),
1266
+ plan: z9.string().optional().describe("Plan tier slug.")
1267
+ },
1268
+ handler: async (args, ctx) => {
1269
+ const teamId = await ctx.resolveTeamId();
1270
+ const input = {};
1271
+ if (args.build_command !== void 0) input["buildCommand"] = args.build_command;
1272
+ if (args.start_command !== void 0) input["startCommand"] = args.start_command;
1273
+ if (args.branch !== void 0) input["branch"] = args.branch;
1274
+ if (args.root_directory !== void 0) input["rootDirectory"] = args.root_directory;
1275
+ if (args.dockerfile_path !== void 0) input["dockerfilePath"] = args.dockerfile_path;
1276
+ if (args.auto_deploy !== void 0) input["autoDeploy"] = args.auto_deploy;
1277
+ if (args.instance_count !== void 0) input["instanceCount"] = args.instance_count;
1278
+ if (args.plan !== void 0) input["plan"] = args.plan;
1279
+ if (Object.keys(input).length === 0) {
1280
+ return respond({ summary: "No fields to update.", data: {} });
1281
+ }
1282
+ const response = await ctx.hoststack.services.updateConfig(teamId, args.service_id, input);
1283
+ const data = { config: shape(response.config) };
1284
+ const fields = Object.keys(input).join(", ");
1285
+ return respond({ summary: `Updated ${fields} on service ${args.service_id}.`, data });
1286
+ }
1287
+ });
1288
+ defineTool({
1289
+ name: "suspend_service",
1290
+ category: "services",
1291
+ description: [
1292
+ "Suspend a service: stop all running instances and pause auto-deploys. The service stays configured and can be resumed later with resume_service.",
1293
+ "",
1294
+ "When to use: the user wants to temporarily stop traffic and billing without deleting the service (for example: dev environment overnight, debugging).",
1295
+ "",
1296
+ "Inputs:",
1297
+ " - service_id: publicId of the service.",
1298
+ "",
1299
+ "Returns: { ok: true }.",
1300
+ "",
1301
+ 'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1302
+ ].join("\n"),
1303
+ input: {
1304
+ service_id: z9.string().describe("Service publicId.")
1305
+ },
1306
+ handler: async (args, ctx) => {
1307
+ const teamId = await ctx.resolveTeamId();
1308
+ await ctx.hoststack.services.suspend(teamId, args.service_id);
1309
+ return respond({ summary: `Suspended service ${args.service_id}.`, data: { ok: true } });
1310
+ }
1311
+ });
1312
+ defineTool({
1313
+ name: "resume_service",
1314
+ category: "services",
1315
+ description: [
1316
+ "Resume a previously suspended service: start instances back up and re-enable auto-deploys.",
1317
+ "",
1318
+ "When to use: the user wants to bring a suspended service back online. Pair with suspend_service.",
1319
+ "",
1320
+ "Inputs:",
1321
+ " - service_id: publicId of the service.",
1322
+ "",
1323
+ "Returns: { ok: true }.",
1324
+ "",
1325
+ 'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1326
+ ].join("\n"),
1327
+ input: {
1328
+ service_id: z9.string().describe("Service publicId.")
1329
+ },
1330
+ handler: async (args, ctx) => {
1331
+ const teamId = await ctx.resolveTeamId();
1332
+ await ctx.hoststack.services.resume(teamId, args.service_id);
1333
+ return respond({ summary: `Resumed service ${args.service_id}.`, data: { ok: true } });
1334
+ }
1335
+ });
1336
+ defineTool({
1337
+ name: "get_service_logs",
1338
+ category: "logs",
1339
+ description: [
1340
+ "Fetch a snapshot of the running container's recent runtime logs (stdout/stderr). For *deploy* logs (build output), use get_deploy_logs instead.",
1341
+ "",
1342
+ "When to use: a service is misbehaving in production and you need to read what it is currently logging. The tool returns a snapshot \u2014 there is no streaming over MCP. Re-call to get newer entries.",
1343
+ "",
1344
+ "Inputs:",
1345
+ " - service_id: publicId of the service.",
1346
+ " - lines (optional): tail size (default 200, max 1000).",
1347
+ " - since (optional): ISO-8601 timestamp; only return entries newer than this.",
1348
+ ' - stream (optional): "stdout" | "stderr". Omit to combine.',
1349
+ "",
1350
+ "Returns: { logs: LogEntry[] | string } \u2014 each entry has { timestamp, level?, stream?, message }. Older HostStack agents return a single string blob.",
1351
+ "",
1352
+ 'Example: get_service_logs({ service_id: "svc_abc", lines: 100 }) \u2192 { logs: [{ timestamp: "2026-04-25T\u2026", message: "GET /health 200" }, \u2026] }'
1353
+ ].join("\n"),
1354
+ input: {
1355
+ service_id: z9.string().describe("Service publicId."),
1356
+ lines: z9.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
1357
+ since: z9.string().datetime().optional().describe("ISO-8601 timestamp lower bound."),
1358
+ stream: z9.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream.")
1359
+ },
1360
+ handler: async (args, ctx) => {
1361
+ const teamId = await ctx.resolveTeamId();
1362
+ const opts = {
1363
+ lines: args.lines ?? 200
1364
+ };
1365
+ if (args.since) opts.since = args.since;
1366
+ if (args.stream) opts.stream = args.stream;
1367
+ const response = await ctx.hoststack.services.getRuntimeLogs(teamId, args.service_id, opts);
1368
+ const count = Array.isArray(response.logs) ? response.logs.length : typeof response.logs === "string" ? response.logs.split("\n").length : 0;
1369
+ return respond({
1370
+ summary: `Fetched ${count} log line${count === 1 ? "" : "s"} for service ${args.service_id}.`,
1371
+ data: { logs: response.logs }
1372
+ });
1373
+ }
1374
+ });
1375
+
1376
+ // src/server-factory.ts
1377
+ var PACKAGE_NAME = "hoststack";
1378
+ var PACKAGE_VERSION = "0.1.0";
1379
+ function createMcpServer(options) {
1380
+ const baseUrl = (options.baseUrl ?? "https://api.hoststack.dev").replace(/\/$/, "");
1381
+ const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });
1382
+ const api = new ApiClient(options.apiKey, baseUrl);
1383
+ const server = new McpServer({
1384
+ name: options.serverInfo?.name ?? PACKAGE_NAME,
1385
+ version: options.serverInfo?.version ?? PACKAGE_VERSION
1386
+ });
1387
+ let teamIdPromise = null;
1388
+ const resolveTeamId = () => {
1389
+ if (!teamIdPromise) {
1390
+ teamIdPromise = api.get("/api/auth/me").then((me) => {
1391
+ if (!me.team?.id) {
1392
+ throw new Error(
1393
+ "No team bound to API key. Generate a team-scoped key in the dashboard."
1394
+ );
1395
+ }
1396
+ return me.team.id;
1397
+ });
1398
+ teamIdPromise.catch(() => {
1399
+ teamIdPromise = null;
1400
+ });
1401
+ }
1402
+ return teamIdPromise;
1403
+ };
1404
+ const ctx = { hoststack, api, resolveTeamId };
1405
+ attachTools(server, ctx, options.onToolCall);
1406
+ attachPrompts(server, ctx);
1407
+ attachResources(server, ctx);
1408
+ return server;
1409
+ }
1410
+ export {
1411
+ PACKAGE_NAME,
1412
+ PACKAGE_VERSION,
1413
+ createMcpServer,
1414
+ listPromptDefinitions,
1415
+ listResourceDefinitions,
1416
+ listToolDefinitions
1417
+ };
1418
+ //# sourceMappingURL=index.js.map