@hoststack.dev/mcp 0.17.0 → 0.18.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/README.md +31 -19
- package/dist/hoststack-mcp.js +773 -158
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +773 -158
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
4
4
|
|
|
5
5
|
// src/version.ts
|
|
6
|
-
var MCP_VERSION = true ? "0.
|
|
6
|
+
var MCP_VERSION = true ? "0.18.0" : "0.0.0-dev";
|
|
7
7
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
8
8
|
|
|
9
9
|
// src/api-client.ts
|
|
@@ -229,7 +229,7 @@ definePrompt({
|
|
|
229
229
|
const text = `Goal: diagnose the most recent failed deploy on service ${service_id} and propose a fix.
|
|
230
230
|
|
|
231
231
|
Plan (use these tools in order):
|
|
232
|
-
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
|
|
232
|
+
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 commitHash.
|
|
233
233
|
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.
|
|
234
234
|
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\`.
|
|
235
235
|
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.
|
|
@@ -511,9 +511,9 @@ defineTool({
|
|
|
511
511
|
name: "list_alerts",
|
|
512
512
|
category: "alerts",
|
|
513
513
|
description: [
|
|
514
|
-
"List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
|
|
514
|
+
"List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, uptime failures (a service stopped answering its public URL), new and regressed error issues (an exception the app reported), auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
|
|
515
515
|
"",
|
|
516
|
-
"When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset.",
|
|
516
|
+
"When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset. For an `error.issue_new` / `error.issue_regressed` entry, `list_error_issues` and `get_error_issue` carry the stack behind it.",
|
|
517
517
|
"",
|
|
518
518
|
'By default events are AGGREGATED by (action, resourceId) so flapping events collapse to one row with a fire count + first/last timestamps \u2014 e.g. "service.auto_restarted on service 31, 8 times in the last hour, last at 14:22". Pass aggregate=false to see every raw row.',
|
|
519
519
|
"",
|
|
@@ -1147,7 +1147,7 @@ defineTool({
|
|
|
1147
1147
|
"Inputs:",
|
|
1148
1148
|
' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
|
|
1149
1149
|
"",
|
|
1150
|
-
'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled),
|
|
1150
|
+
'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitHash (the resolved commit SHA; the key is OMITTED entirely when the deploy recorded none, so a missing key means "no commit", not "wrong key"), commitMessage, branch, triggeredBy, startedAt, finishedAt, imageBuildMs (docker build / image-pull only; null on skip-build redeploys \u2014 v89), containerBootMs (deploying \u2192 live wall-clock; null on builds that failed before container start \u2014 v89), buildDurationMs (legacy alias of imageBuildMs kept for back-compat), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt). Use imageBuildMs + containerBootMs together to tell "build is slow" apart from "boot is slow".',
|
|
1151
1151
|
"",
|
|
1152
1152
|
'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
|
|
1153
1153
|
].join("\n"),
|
|
@@ -1174,7 +1174,7 @@ defineTool({
|
|
|
1174
1174
|
" - service_id: publicId of the service.",
|
|
1175
1175
|
' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
|
|
1176
1176
|
"",
|
|
1177
|
-
"Returns: { deploy: Deploy } \u2014 full deploy record (status,
|
|
1177
|
+
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitHash (omitted when the deploy recorded no commit), commitMessage, branch, imageBuildMs + containerBootMs (v89 split timings; legacy buildDurationMs preserved as alias), totalDurationMs, finishedAt, etc).",
|
|
1178
1178
|
"",
|
|
1179
1179
|
'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
|
|
1180
1180
|
].join("\n"),
|
|
@@ -1394,7 +1394,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1394
1394
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1395
1395
|
if (input.zone_id) {
|
|
1396
1396
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1397
|
-
const match = zones2.find((
|
|
1397
|
+
const match = zones2.find((z20) => z20.publicId === input.zone_id);
|
|
1398
1398
|
if (!match) {
|
|
1399
1399
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1400
1400
|
}
|
|
@@ -1408,7 +1408,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1408
1408
|
const labels = fqdn.split(".");
|
|
1409
1409
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1410
1410
|
const candidate = labels.slice(i).join(".");
|
|
1411
|
-
const match = zones.find((
|
|
1411
|
+
const match = zones.find((z20) => z20.domainName.toLowerCase() === candidate);
|
|
1412
1412
|
if (match && match.status !== "deleting") {
|
|
1413
1413
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1414
1414
|
}
|
|
@@ -2151,6 +2151,437 @@ defineTool({
|
|
|
2151
2151
|
}
|
|
2152
2152
|
});
|
|
2153
2153
|
|
|
2154
|
+
// src/tools/analytics.ts
|
|
2155
|
+
import { z as z12 } from "zod";
|
|
2156
|
+
async function resolveSiteIds(domains, teamId, api) {
|
|
2157
|
+
if (!domains || domains.length === 0) return void 0;
|
|
2158
|
+
const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
|
|
2159
|
+
const ids = [];
|
|
2160
|
+
const missing = [];
|
|
2161
|
+
for (const domain of domains) {
|
|
2162
|
+
const needle = domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2163
|
+
const match = sites.find((s) => s.domain === needle);
|
|
2164
|
+
if (match) ids.push(match.id);
|
|
2165
|
+
else missing.push(domain);
|
|
2166
|
+
}
|
|
2167
|
+
if (missing.length > 0) {
|
|
2168
|
+
throw new Error(
|
|
2169
|
+
`Not tracking ${missing.join(", ")}. Known sites: ${sites.map((s) => s.domain).join(", ") || "none yet"}.`
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
return ids.join(",");
|
|
2173
|
+
}
|
|
2174
|
+
defineTool({
|
|
2175
|
+
name: "list_analytics_sites",
|
|
2176
|
+
category: "analytics",
|
|
2177
|
+
description: [
|
|
2178
|
+
"List the websites this team tracks \u2014 domains, their site keys, retention, and which service (if any) serves each one.",
|
|
2179
|
+
"",
|
|
2180
|
+
"When to use: before any other analytics call, to learn which domains exist; or to fetch the script tag a site needs.",
|
|
2181
|
+
"",
|
|
2182
|
+
"The site key is PUBLIC by design \u2014 it ships in a `data-site-key` attribute that anyone can view-source, like a Sentry DSN. It is write-only and scoped to one site. Do not treat it as a secret, and do not confuse it with the team API key.",
|
|
2183
|
+
"",
|
|
2184
|
+
"Returns: { sites: Array }. Each site has id, publicId, name, domain, ingestKey, allowedOrigins, serviceId, serviceName, retentionDays, droppedCount (events refused by the hourly quota and therefore MISSING from every count for that site), and keyGraceEndsAt when a rotation is still in its grace window.",
|
|
2185
|
+
"",
|
|
2186
|
+
'Each site also carries lastEventAt (null means NOTHING has ever reached ingest for that key) plus lastRefusalReason and refusedRecently, a 7-day tally by reason. An empty chart with lastEventAt set is a site with no traffic; an empty chart with lastEventAt null is a broken setup. Do not report "no visitors" without checking which one you are looking at \u2014 call check_analytics_site for the diagnosis.',
|
|
2187
|
+
"",
|
|
2188
|
+
"Example: list_analytics_sites({}) \u2192 { sites: [{ id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026', retentionDays: 30, serviceName: 'micci-web' }] }."
|
|
2189
|
+
].join("\n"),
|
|
2190
|
+
input: {},
|
|
2191
|
+
handler: async (_args, ctx) => {
|
|
2192
|
+
const teamId = await ctx.resolveTeamId();
|
|
2193
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/sites`);
|
|
2194
|
+
const items = Array.isArray(response.sites) ? response.sites.map(shape) : [];
|
|
2195
|
+
const summary = items.length === 0 ? "No analytics sites yet. Create one with create_analytics_site." : `Tracking ${items.length} site${items.length === 1 ? "" : "s"}.`;
|
|
2196
|
+
return respond({ summary, data: { sites: items } });
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
defineTool({
|
|
2200
|
+
name: "check_analytics_site",
|
|
2201
|
+
category: "analytics",
|
|
2202
|
+
description: [
|
|
2203
|
+
"Why a site is reporting nothing \u2014 the diagnosis, without having to generate traffic and guess.",
|
|
2204
|
+
"",
|
|
2205
|
+
"When to use: any time a chart is empty, a site was just set up, a key was rotated, or someone asks whether the snippet is installed correctly. Call this BEFORE telling anyone their traffic dropped.",
|
|
2206
|
+
"",
|
|
2207
|
+
"The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
|
|
2208
|
+
"",
|
|
2209
|
+
"Returns health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
|
|
2210
|
+
"",
|
|
2211
|
+
"health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
|
|
2212
|
+
"",
|
|
2213
|
+
"Example: check_analytics_site({ domain: 'example.com' }) \u2192 { health: 'refusing', headline: 'Reaching us, and being refused.', detail: 'Something on example.com posted 214 event(s) with a site key we do not recognise\u2026', lastEventAt: null, refusedRecently: { unknown_key: 214, bad_origin: 0, bot: 3, over_quota: 0 } }."
|
|
2214
|
+
].join("\n"),
|
|
2215
|
+
input: {
|
|
2216
|
+
domain: z12.string().max(253).describe("Bare hostname of a site this team tracks.")
|
|
2217
|
+
},
|
|
2218
|
+
handler: async (args, ctx) => {
|
|
2219
|
+
const teamId = await ctx.resolveTeamId();
|
|
2220
|
+
const siteIds = await resolveSiteIds([args.domain], teamId, ctx.api);
|
|
2221
|
+
const status = await ctx.api.get(
|
|
2222
|
+
`/api/analytics/${teamId}/sites/${siteIds}/status`
|
|
2223
|
+
);
|
|
2224
|
+
return respond({ summary: `${args.domain}: ${status.headline}`, data: shape(status) });
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
defineTool({
|
|
2228
|
+
name: "get_analytics_summary",
|
|
2229
|
+
category: "analytics",
|
|
2230
|
+
description: [
|
|
2231
|
+
"One row per site: visitors, pageviews, bounce rate, average visit and live visitors, each with the previous period to compare against.",
|
|
2232
|
+
"",
|
|
2233
|
+
'When to use: "how are my sites doing", a weekly check, or spotting which of several domains moved. This is the cross-site view; use get_analytics_overview for the breakdowns behind one of them.',
|
|
2234
|
+
"",
|
|
2235
|
+
"READ THE VISITOR NUMBER CAREFULLY. Inside a site's raw-event window (35 days by default, so 24h through 30d are raw) `visitors` is a true distinct count over the range. Beyond it the answer comes from a daily rollup and `visitors` is each day's uniques ADDED TOGETHER \u2014 someone who visits daily counts once per day. `visitorsAreSummedDailies` says which you got; quote it when reporting a 90d or 12mo number.",
|
|
2236
|
+
"",
|
|
2237
|
+
"Visitors are never summed ACROSS sites. The same person on two domains is two session ids, and reconciling them would require the cross-domain identity a cookieless tracker exists not to build. Add pageviews if you need a total; do not add visitors.",
|
|
2238
|
+
"",
|
|
2239
|
+
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
|
|
2240
|
+
"",
|
|
2241
|
+
"Example: get_analytics_summary({ range: '30d' }) \u2192 { range: '30d', sites: [{ domain: 'hoststack.dev', current: { visitors: 4210, pageviews: 11890, bounceRate: 47.2 }, previous: {\u2026}, live: 3, visitorsAreSummedDailies: false }] }."
|
|
2242
|
+
].join("\n"),
|
|
2243
|
+
input: {
|
|
2244
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2245
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks.")
|
|
2246
|
+
},
|
|
2247
|
+
handler: async (args, ctx) => {
|
|
2248
|
+
const teamId = await ctx.resolveTeamId();
|
|
2249
|
+
const siteIds = await resolveSiteIds(args.domains, teamId, ctx.api);
|
|
2250
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/summary`, {
|
|
2251
|
+
range: args.range ?? "7d",
|
|
2252
|
+
...siteIds ? { siteIds } : {}
|
|
2253
|
+
});
|
|
2254
|
+
const sites = Array.isArray(response.sites) ? response.sites : [];
|
|
2255
|
+
const summed = sites.some((s) => s.visitorsAreSummedDailies);
|
|
2256
|
+
const summary = sites.length === 0 ? "No sites to report on." : `${sites.length} site${sites.length === 1 ? "" : "s"} over ${response.range}.${summed ? " Visitor counts are daily uniques summed, not distinct over the range \u2014 this range is past the raw-event window." : ""}`;
|
|
2257
|
+
return respond({ summary, data: { range: response.range, sites: sites.map(shape) } });
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
defineTool({
|
|
2261
|
+
name: "get_analytics_overview",
|
|
2262
|
+
category: "analytics",
|
|
2263
|
+
description: [
|
|
2264
|
+
"The full breakdown for one site (or several): timeseries, top paths, referrers, custom events, browsers, operating systems, languages, screen sizes, campaigns, devices and countries.",
|
|
2265
|
+
"",
|
|
2266
|
+
"When to use: after get_analytics_summary has told you WHICH site moved, and you need to see what drove it \u2014 a referrer that appeared, a campaign that landed, a path that spiked.",
|
|
2267
|
+
"",
|
|
2268
|
+
"Two things change past the raw-event window (35 days by default, so 24h through 30d are raw): `visitors` becomes daily uniques summed rather than a distinct count (`visitorsAreSummedDailies`), and FILTERS ARE IGNORED because the rollup stores per-day aggregates with no events left to filter (`filtersSupported: false`). Check both fields before drawing a conclusion; do not silently present a filtered request that was not filtered.",
|
|
2269
|
+
"",
|
|
2270
|
+
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site), filters (optional map \u2014 country, browser, os, device, language, path, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent).",
|
|
2271
|
+
"",
|
|
2272
|
+
"Example: get_analytics_overview({ domains: ['hoststack.dev'], range: '7d', filters: { country: 'DK' } }) \u2192 { source: 'raw', filtersSupported: true, summary: { current: { visitors: 310, \u2026 } }, topPaths: [{ path: '/pricing', pageviews: 214 }], topReferrers: [{ referrer: 'Direct', visits: 190 }], \u2026 }."
|
|
2273
|
+
].join("\n"),
|
|
2274
|
+
input: {
|
|
2275
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2276
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks."),
|
|
2277
|
+
filters: z12.record(z12.string(), z12.string().max(200)).optional().describe("Dimension filters. Ignored for ranges past the raw-event window.")
|
|
2278
|
+
},
|
|
2279
|
+
handler: async (args, ctx) => {
|
|
2280
|
+
const teamId = await ctx.resolveTeamId();
|
|
2281
|
+
const siteIds = await resolveSiteIds(args.domains, teamId, ctx.api);
|
|
2282
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/overview`, {
|
|
2283
|
+
range: args.range ?? "7d",
|
|
2284
|
+
...siteIds ? { siteIds } : {},
|
|
2285
|
+
...args.filters ?? {}
|
|
2286
|
+
});
|
|
2287
|
+
const { current } = response.summary;
|
|
2288
|
+
const caveats = [];
|
|
2289
|
+
if (response.visitorsAreSummedDailies) {
|
|
2290
|
+
caveats.push(
|
|
2291
|
+
"visitors are daily uniques summed (this range is past the raw-event window)"
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
if (args.filters && Object.keys(args.filters).length > 0 && !response.filtersSupported) {
|
|
2295
|
+
caveats.push(
|
|
2296
|
+
"the filters you passed were NOT applied \u2014 they only work on shorter ranges"
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
const summary = `${current.pageviews.toLocaleString()} pageviews from ${current.visitors.toLocaleString()} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
|
|
2300
|
+
return respond({ summary, data: response });
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
defineTool({
|
|
2304
|
+
name: "create_analytics_site",
|
|
2305
|
+
category: "analytics",
|
|
2306
|
+
description: [
|
|
2307
|
+
"Start tracking a domain, and return the script tag to paste into its <head>.",
|
|
2308
|
+
"",
|
|
2309
|
+
"When to use: the user wants analytics for a site that is not in list_analytics_sites yet. Works for any domain, whether or not it is hosted on HostStack.",
|
|
2310
|
+
"",
|
|
2311
|
+
"If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
|
|
2312
|
+
"",
|
|
2313
|
+
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
|
|
2314
|
+
"",
|
|
2315
|
+
`Example: create_analytics_site({ domain: 'micci.dk' }) \u2192 { site: { id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026' }, snippet: '<script defer src="https://hoststack.dev/t.js" data-site-key="site_\u2026"></script>' }.`
|
|
2316
|
+
].join("\n"),
|
|
2317
|
+
input: {
|
|
2318
|
+
domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
|
|
2319
|
+
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
|
|
2320
|
+
},
|
|
2321
|
+
handler: async (args, ctx) => {
|
|
2322
|
+
const teamId = await ctx.resolveTeamId();
|
|
2323
|
+
const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
|
|
2324
|
+
domain: args.domain,
|
|
2325
|
+
...args.name ? { name: args.name } : {}
|
|
2326
|
+
});
|
|
2327
|
+
const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
|
|
2328
|
+
return respond({
|
|
2329
|
+
summary: `Now tracking ${site.domain}. Paste the snippet into its <head> \u2014 nothing is counted until you do.`,
|
|
2330
|
+
data: { site: shape(site), snippet }
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
// src/tools/errors.ts
|
|
2336
|
+
import { z as z13 } from "zod";
|
|
2337
|
+
defineTool({
|
|
2338
|
+
name: "list_error_issues",
|
|
2339
|
+
category: "errors",
|
|
2340
|
+
description: [
|
|
2341
|
+
"List error issues for the team \u2014 exceptions the team's own applications reported, grouped by cause rather than listed one per event.",
|
|
2342
|
+
"",
|
|
2343
|
+
'When to use: "what is throwing in production", triaging after a deploy, or finding the bug behind a user report. Pairs with list_alerts (which tells you an alert fired) and get_service_logs (which shows the raw output around it).',
|
|
2344
|
+
"",
|
|
2345
|
+
'Grouping: an issue is one distinct problem. Its fingerprint is the exception class, the message with variable parts removed (so "user 41 not found" and "user 9002 not found" are ONE issue), and the topmost stack frame in the team\'s own code \u2014 not the framework\'s. `culprit` is that frame, and it is the line to open first.',
|
|
2346
|
+
"",
|
|
2347
|
+
"Counts are exact; occurrence samples are not. `occurrenceCount` is the true number of times it happened. A bounded number of full occurrences is retained per issue (see get_error_issue), and `droppedCount` is how many were counted but not stored because the service exceeded its hourly ingest quota.",
|
|
2348
|
+
"",
|
|
2349
|
+
'Defaults to status=unresolved, because the list exists to answer "what is broken". Pass status=resolved or status=ignored for the others.',
|
|
2350
|
+
"",
|
|
2351
|
+
"Inputs (all optional): serviceId, status (unresolved|resolved|ignored), q (substring match on title/culprit), limit (default 50, max 200), offset, sort (last_seen|first_seen|count).",
|
|
2352
|
+
"",
|
|
2353
|
+
'Returns: { issues: Array, total: number }. Each issue has id, publicId, serviceId, serviceName, type, title, culprit, level, status, occurrenceCount, droppedCount, affectedUsers (capped at 500 \u2014 500 means "500 or more"), firstSeenAt, lastSeenAt, firstSeenRelease, lastSeenRelease, environment, sourceTaskPublicId (a dev-box task already open for it).',
|
|
2354
|
+
"",
|
|
2355
|
+
"Example: list_error_issues({ serviceId: 48, sort: 'count' }) \u2192 { issues: [{ id: 12, type: 'TypeError', title: 'TypeError: cart.total is not a function', culprit: 'src/checkout.ts:12 in checkout', occurrenceCount: 4187, affectedUsers: 96, lastSeenRelease: 'a91f3c2', \u2026 }], total: 3 }."
|
|
2356
|
+
].join("\n"),
|
|
2357
|
+
input: {
|
|
2358
|
+
serviceId: z13.number().int().positive().optional().describe("Only issues for this service."),
|
|
2359
|
+
status: z13.enum(["unresolved", "resolved", "ignored"]).optional().describe("Default unresolved."),
|
|
2360
|
+
q: z13.string().max(200).optional().describe("Substring match on title or culprit."),
|
|
2361
|
+
limit: z13.number().int().positive().max(200).optional().describe("Default 50, cap 200."),
|
|
2362
|
+
offset: z13.number().int().min(0).optional(),
|
|
2363
|
+
sort: z13.enum(["last_seen", "first_seen", "count"]).optional().describe("Default last_seen.")
|
|
2364
|
+
},
|
|
2365
|
+
handler: async (args, ctx) => {
|
|
2366
|
+
const teamId = await ctx.resolveTeamId();
|
|
2367
|
+
const params = {};
|
|
2368
|
+
for (const key of ["serviceId", "status", "q", "limit", "offset", "sort"]) {
|
|
2369
|
+
const value = args[key];
|
|
2370
|
+
if (value !== void 0) params[key] = String(value);
|
|
2371
|
+
}
|
|
2372
|
+
const response = await ctx.api.get(`/api/errors/${teamId}/issues`, params);
|
|
2373
|
+
const items = Array.isArray(response.issues) ? response.issues.map(shape) : [];
|
|
2374
|
+
const summary = items.length === 0 ? `No ${args.status ?? "unresolved"} error issues${args.serviceId ? " for that service" : ""}.` : `Returned ${items.length} of ${response.total} ${args.status ?? "unresolved"} issue${response.total === 1 ? "" : "s"}.`;
|
|
2375
|
+
return respond({ summary, data: { issues: items, total: response.total } });
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
defineTool({
|
|
2379
|
+
name: "get_error_issue",
|
|
2380
|
+
category: "errors",
|
|
2381
|
+
description: [
|
|
2382
|
+
"Fetch one error issue plus its most recent stored occurrences \u2014 the stack traces, request context and releases behind the aggregate.",
|
|
2383
|
+
"",
|
|
2384
|
+
"When to use: after list_error_issues has told you WHICH problem to look at, and you need the actual stack to reason about the cause.",
|
|
2385
|
+
"",
|
|
2386
|
+
"Occurrences are SAMPLES, not the full history: a bounded number is kept per issue while the count stays exact, and they age out after 30 days while the issue itself remains. An issue whose samples have expired returns an empty occurrences array \u2014 that is normal, not an error.",
|
|
2387
|
+
"",
|
|
2388
|
+
"Each occurrence has stack (raw, as the runtime printed it), context (tenant-supplied JSON, with sensitive keys already redacted), requestId, release, environment and createdAt.",
|
|
2389
|
+
"",
|
|
2390
|
+
"Inputs: issueId (required), occurrences (how many samples, default 5, max 50).",
|
|
2391
|
+
"",
|
|
2392
|
+
"Returns: { issue: {...}, occurrences: Array }.",
|
|
2393
|
+
"",
|
|
2394
|
+
"Example: get_error_issue({ issueId: 12 }) \u2192 { issue: { title: 'TypeError: cart.total is not a function', occurrenceCount: 4187, \u2026 }, occurrences: [{ stack: ' at checkout (/app/src/checkout.ts:12:9)\u2026', requestId: 'req_9f3c', release: 'a91f3c2', createdAt: '\u2026' }] }."
|
|
2395
|
+
].join("\n"),
|
|
2396
|
+
input: {
|
|
2397
|
+
issueId: z13.number().int().positive().describe("Numeric issue id from list_error_issues."),
|
|
2398
|
+
occurrences: z13.number().int().positive().max(50).optional().describe("How many samples to include. Default 5.")
|
|
2399
|
+
},
|
|
2400
|
+
handler: async (args, ctx) => {
|
|
2401
|
+
const teamId = await ctx.resolveTeamId();
|
|
2402
|
+
const [issueRes, occRes] = await Promise.all([
|
|
2403
|
+
ctx.api.get(`/api/errors/${teamId}/issues/${args.issueId}`),
|
|
2404
|
+
ctx.api.get(
|
|
2405
|
+
`/api/errors/${teamId}/issues/${args.issueId}/occurrences`,
|
|
2406
|
+
{ limit: String(args.occurrences ?? 5) }
|
|
2407
|
+
)
|
|
2408
|
+
]);
|
|
2409
|
+
const occurrences = Array.isArray(occRes.occurrences) ? occRes.occurrences.map(shape) : [];
|
|
2410
|
+
const issue = shape(issueRes.issue);
|
|
2411
|
+
return respond({
|
|
2412
|
+
summary: `${String(issue["title"] ?? "Issue")} \u2014 ${String(issue["occurrenceCount"] ?? 0)} occurrence(s), ${occurrences.length} sample(s) available.`,
|
|
2413
|
+
data: { issue, occurrences }
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
defineTool({
|
|
2418
|
+
name: "update_error_issue",
|
|
2419
|
+
category: "errors",
|
|
2420
|
+
description: [
|
|
2421
|
+
"Resolve, ignore or reopen an error issue.",
|
|
2422
|
+
"",
|
|
2423
|
+
"When to use: after fixing a bug (resolve, so a recurrence is reported as a regression), or to silence noise you have decided to live with (ignore).",
|
|
2424
|
+
"",
|
|
2425
|
+
"`resolved` records the release it was resolved in. If the issue occurs again afterwards it reopens itself and fires an `error.issue_regressed` alert \u2014 which is the point of resolving rather than ignoring, and the reason not to resolve something you have not actually fixed.",
|
|
2426
|
+
"",
|
|
2427
|
+
"`ignored` keeps counting occurrences and stops telling anyone. An ignored issue never reopens itself, so this is the right choice for noise you have decided to live with, and the wrong choice for something you intend to fix.",
|
|
2428
|
+
"",
|
|
2429
|
+
"`unresolved` reopens it.",
|
|
2430
|
+
"",
|
|
2431
|
+
"Inputs: issueId (required), status (required).",
|
|
2432
|
+
"",
|
|
2433
|
+
"Returns: { issue: {...} } with the updated row.",
|
|
2434
|
+
"",
|
|
2435
|
+
"Example: update_error_issue({ issueId: 12, status: 'resolved' }) \u2192 { issue: { status: 'resolved', resolvedInRelease: 'a91f3c2', \u2026 } }."
|
|
2436
|
+
].join("\n"),
|
|
2437
|
+
input: {
|
|
2438
|
+
issueId: z13.number().int().positive(),
|
|
2439
|
+
status: z13.enum(["unresolved", "resolved", "ignored"])
|
|
2440
|
+
},
|
|
2441
|
+
handler: async (args, ctx) => {
|
|
2442
|
+
const teamId = await ctx.resolveTeamId();
|
|
2443
|
+
const response = await ctx.api.patch(
|
|
2444
|
+
`/api/errors/${teamId}/issues/${args.issueId}`,
|
|
2445
|
+
{ status: args.status }
|
|
2446
|
+
);
|
|
2447
|
+
return respond({
|
|
2448
|
+
summary: `Issue ${args.issueId} is now ${args.status}.`,
|
|
2449
|
+
data: { issue: shape(response.issue) }
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
defineTool({
|
|
2454
|
+
name: "fix_error_in_dev_box",
|
|
2455
|
+
category: "errors",
|
|
2456
|
+
description: [
|
|
2457
|
+
"Turn an error issue into a coding-agent task in the project's dev box, with the repository already checked out.",
|
|
2458
|
+
"",
|
|
2459
|
+
"When to use: the issue is real, it is in the team's own code, and somebody is going to have to open the repository anyway. This writes the whole briefing so nobody has to reconstruct it from the dashboard.",
|
|
2460
|
+
"",
|
|
2461
|
+
"This is the thing a third-party error tracker structurally cannot do. The task carries the exception, the stack with the team's OWN frames marked (`>>`), the release it happened on, how often and to how many users, and a real request context \u2014 plus hard boundaries: work on a branch, do not deploy, do not delete tests, and stop and say so if the cause turns out not to be in this repository.",
|
|
2462
|
+
"",
|
|
2463
|
+
"It CREATES the task; it does not start the agent. Running spends the team's own agent tokens and is a separate decision made in the dev box UI.",
|
|
2464
|
+
"",
|
|
2465
|
+
"Refused with 422 when the cause cannot be attributed to the repository \u2014 every stack frame inside a dependency, no usable stack at all, a browser extension, or a connection failure with no in-app frame. That gate is deliberate: an agent told to fix something it cannot reach does not refuse, it produces a confident and useless diff.",
|
|
2466
|
+
"",
|
|
2467
|
+
"Pressing this twice for one issue returns the existing task (alreadyExisted=true) rather than queueing a duplicate run.",
|
|
2468
|
+
"",
|
|
2469
|
+
"Inputs: issueId (required), serviceId (optional \u2014 pin to a specific dev box; omitted, the project's box is used).",
|
|
2470
|
+
"",
|
|
2471
|
+
"Returns: { task, alreadyExisted, box: { serviceId, name, asleep }, automodeEnabled }.",
|
|
2472
|
+
"",
|
|
2473
|
+
"Example: fix_error_in_dev_box({ issueId: 12 }) \u2192 { task: { title: 'Fix error: TypeError in src/checkout.ts:12' }, alreadyExisted: false, box: { name: 'shop-dev', asleep: true }, automodeEnabled: false }."
|
|
2474
|
+
].join("\n"),
|
|
2475
|
+
input: {
|
|
2476
|
+
issueId: z13.number().int().positive(),
|
|
2477
|
+
serviceId: z13.number().int().positive().optional().describe("Dev box to pin the task to. Omitted, the project's box is used.")
|
|
2478
|
+
},
|
|
2479
|
+
handler: async (args, ctx) => {
|
|
2480
|
+
const teamId = await ctx.resolveTeamId();
|
|
2481
|
+
const body = { issueId: args.issueId };
|
|
2482
|
+
if (args.serviceId !== void 0) body["serviceId"] = args.serviceId;
|
|
2483
|
+
const response = await ctx.api.post(
|
|
2484
|
+
`/api/dev-env-tasks/${teamId}/from-issue`,
|
|
2485
|
+
body
|
|
2486
|
+
);
|
|
2487
|
+
const verb = response.alreadyExisted ? "Already queued" : "Queued";
|
|
2488
|
+
const note = response.box.asleep ? " That box is asleep \u2014 resume it before running the task." : "";
|
|
2489
|
+
return respond({
|
|
2490
|
+
summary: `${verb} in dev box "${response.box.name}": ${response.task.title ?? "task"}.${note}`,
|
|
2491
|
+
data: shape(response)
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
defineTool({
|
|
2496
|
+
name: "list_ingest_keys",
|
|
2497
|
+
category: "errors",
|
|
2498
|
+
description: [
|
|
2499
|
+
"List a service's error-ingest keys. Only prefixes and last-used timestamps \u2014 the key itself is stored as a hash and is never readable again.",
|
|
2500
|
+
"",
|
|
2501
|
+
"When to use: checking whether a service is wired up for error reporting at all, or whether an old key is still in use before revoking it (`lastUsedAt` is stamped lazily, at most once a minute).",
|
|
2502
|
+
"",
|
|
2503
|
+
"Inputs: serviceId (required).",
|
|
2504
|
+
"",
|
|
2505
|
+
"Returns: { keys: Array } with id, publicId, name, prefix, lastUsedAt, createdAt.",
|
|
2506
|
+
"",
|
|
2507
|
+
"Example: list_ingest_keys({ serviceId: 48 }) \u2192 { keys: [{ id: 3, name: 'default', prefix: 'ing_9f3c1ab2', lastUsedAt: '2026-08-21T09:14:00Z' }] }."
|
|
2508
|
+
].join("\n"),
|
|
2509
|
+
input: { serviceId: z13.number().int().positive() },
|
|
2510
|
+
handler: async (args, ctx) => {
|
|
2511
|
+
const teamId = await ctx.resolveTeamId();
|
|
2512
|
+
const response = await ctx.api.get(
|
|
2513
|
+
`/api/services/${teamId}/${args.serviceId}/ingest-keys`
|
|
2514
|
+
);
|
|
2515
|
+
const keys = Array.isArray(response.keys) ? response.keys.map(shape) : [];
|
|
2516
|
+
return respond({
|
|
2517
|
+
summary: keys.length === 0 ? "No ingest keys on this service \u2014 it cannot report errors yet." : `${keys.length} ingest key${keys.length === 1 ? "" : "s"} on this service.`,
|
|
2518
|
+
data: { keys }
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
});
|
|
2522
|
+
defineTool({
|
|
2523
|
+
name: "create_ingest_key",
|
|
2524
|
+
category: "errors",
|
|
2525
|
+
description: [
|
|
2526
|
+
"Mint a write-only error-ingest key for a service, so its application can report exceptions.",
|
|
2527
|
+
"",
|
|
2528
|
+
"When to use: setting a service up for error tracking for the first time, or rotating a key that has leaked or is being retired.",
|
|
2529
|
+
"",
|
|
2530
|
+
"The response is the ONLY place the plaintext key ever exists \u2014 it is stored as a SHA-256 hash and cannot be shown again. Put it wherever the application reads it from (an env var, usually) before discarding the response.",
|
|
2531
|
+
"",
|
|
2532
|
+
"The key is deliberately low-privilege: it can create error events for this ONE service and can do nothing else \u2014 it cannot read the issues it created, list services, or touch anything on the team. That is why shipping it inside the application, including a browser bundle where it is world-readable, is expected rather than a mistake.",
|
|
2533
|
+
"",
|
|
2534
|
+
'Reporting is a plain HTTP POST, no SDK required: POST {baseUrl}/api/ingest/errors/{key} with {"events":[{"type","value","stack","level","context"}]}, up to 100 events per request.',
|
|
2535
|
+
"",
|
|
2536
|
+
'Inputs: serviceId (required), name (optional label, default "default"). Max 5 keys per service \u2014 the extras exist so a key can be rotated without downtime (create the second, ship it, delete the first).',
|
|
2537
|
+
"",
|
|
2538
|
+
"Returns: { key: { id, publicId, name, prefix, key } } where `key` is the plaintext.",
|
|
2539
|
+
"",
|
|
2540
|
+
"Example: create_ingest_key({ serviceId: 48 }) \u2192 { id: 4, name: 'default', prefix: 'ing_1b7d40ae', key: 'ing_1b7d40ae\u2026' }."
|
|
2541
|
+
].join("\n"),
|
|
2542
|
+
input: {
|
|
2543
|
+
serviceId: z13.number().int().positive(),
|
|
2544
|
+
name: z13.string().min(1).max(100).optional().describe('Label. Default "default".')
|
|
2545
|
+
},
|
|
2546
|
+
handler: async (args, ctx) => {
|
|
2547
|
+
const teamId = await ctx.resolveTeamId();
|
|
2548
|
+
const response = await ctx.api.post(
|
|
2549
|
+
`/api/services/${teamId}/${args.serviceId}/ingest-keys`,
|
|
2550
|
+
{ name: args.name ?? "default" }
|
|
2551
|
+
);
|
|
2552
|
+
return respond({
|
|
2553
|
+
summary: "Ingest key created. The plaintext key is in the payload and is not retrievable again \u2014 store it now.",
|
|
2554
|
+
data: shape(response.key)
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
});
|
|
2558
|
+
defineTool({
|
|
2559
|
+
name: "delete_ingest_key",
|
|
2560
|
+
category: "errors",
|
|
2561
|
+
description: [
|
|
2562
|
+
"Revoke an error-ingest key. It stops working immediately, including on API replicas that had it cached.",
|
|
2563
|
+
"",
|
|
2564
|
+
"When to use: finishing a key rotation, or containing a key that leaked somewhere it should not have.",
|
|
2565
|
+
"",
|
|
2566
|
+
"Anything still posting with it starts getting 401s, so revoke the OLD key after the new one is deployed, not before.",
|
|
2567
|
+
"",
|
|
2568
|
+
"Inputs: serviceId, keyId (both required).",
|
|
2569
|
+
"",
|
|
2570
|
+
"Returns: { success: true }.",
|
|
2571
|
+
"",
|
|
2572
|
+
"Example: delete_ingest_key({ serviceId: 48, keyId: 3 }) \u2192 { success: true }."
|
|
2573
|
+
].join("\n"),
|
|
2574
|
+
input: {
|
|
2575
|
+
serviceId: z13.number().int().positive(),
|
|
2576
|
+
keyId: z13.number().int().positive()
|
|
2577
|
+
},
|
|
2578
|
+
handler: async (args, ctx) => {
|
|
2579
|
+
const teamId = await ctx.resolveTeamId();
|
|
2580
|
+
await ctx.api.delete(`/api/services/${teamId}/${args.serviceId}/ingest-keys/${args.keyId}`);
|
|
2581
|
+
return respond({ summary: `Ingest key ${args.keyId} revoked.` });
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
|
|
2154
2585
|
// src/tools/github.ts
|
|
2155
2586
|
defineTool({
|
|
2156
2587
|
name: "sync_github_repos",
|
|
@@ -2258,7 +2689,7 @@ defineTool({
|
|
|
2258
2689
|
});
|
|
2259
2690
|
|
|
2260
2691
|
// src/tools/notifications.ts
|
|
2261
|
-
import { z as
|
|
2692
|
+
import { z as z14 } from "zod";
|
|
2262
2693
|
var NOTIFICATION_EVENTS = [
|
|
2263
2694
|
"deploy.started",
|
|
2264
2695
|
"deploy.succeeded",
|
|
@@ -2272,9 +2703,25 @@ var NOTIFICATION_EVENTS = [
|
|
|
2272
2703
|
"service.auto_suspended",
|
|
2273
2704
|
"service.acme_cert_failed",
|
|
2274
2705
|
"service.resource_alert",
|
|
2706
|
+
"service.uptime_down",
|
|
2707
|
+
"service.uptime_recovered",
|
|
2708
|
+
"error.issue_new",
|
|
2709
|
+
"error.issue_regressed",
|
|
2275
2710
|
"git.auth_failed",
|
|
2276
2711
|
"cron.execution_failed",
|
|
2277
|
-
"workflow.failed"
|
|
2712
|
+
"workflow.failed",
|
|
2713
|
+
"devenv.agent.needs_input",
|
|
2714
|
+
"devenv.agent.finished",
|
|
2715
|
+
"devenv.task.created",
|
|
2716
|
+
"devenv.task.needs_input",
|
|
2717
|
+
"devenv.task.finished",
|
|
2718
|
+
"database.backup_failed",
|
|
2719
|
+
"database.restore_failed",
|
|
2720
|
+
"machine.offline",
|
|
2721
|
+
"machine.online",
|
|
2722
|
+
"billing.invoice",
|
|
2723
|
+
"billing.payment_failed",
|
|
2724
|
+
"billing.spend_limit"
|
|
2278
2725
|
];
|
|
2279
2726
|
defineTool({
|
|
2280
2727
|
name: "list_notification_channels",
|
|
@@ -2313,17 +2760,21 @@ defineTool({
|
|
|
2313
2760
|
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
2314
2761
|
" - events: list of event names to subscribe to. Pass an empty list to create a channel that fires for nothing (manual subscribe later with update_notification_channel).",
|
|
2315
2762
|
"",
|
|
2316
|
-
|
|
2763
|
+
// Rendered from the enum instead of retyped: the hand-written copy of
|
|
2764
|
+
// this sentence went stale the same way the enum did, and a description
|
|
2765
|
+
// that disagrees with the schema teaches the model to send values the
|
|
2766
|
+
// tool then rejects.
|
|
2767
|
+
`Valid events: ${NOTIFICATION_EVENTS.join(", ")}.`,
|
|
2317
2768
|
"",
|
|
2318
2769
|
"Returns: { channel: Channel }.",
|
|
2319
2770
|
"",
|
|
2320
2771
|
"Example: create_notification_channel({ type: 'slack', name: 'eng-alerts', webhook_url: 'https://hooks.slack.com/\u2026', events: ['deploy.failed', 'git.auth_failed', 'service.restart_failed'] })"
|
|
2321
2772
|
].join("\n"),
|
|
2322
2773
|
input: {
|
|
2323
|
-
type:
|
|
2324
|
-
name:
|
|
2325
|
-
webhook_url:
|
|
2326
|
-
events:
|
|
2774
|
+
type: z14.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
2775
|
+
name: z14.string().min(1).max(128).describe("Human-readable label."),
|
|
2776
|
+
webhook_url: z14.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
2777
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).describe(
|
|
2327
2778
|
"List of events the channel subscribes to. Empty list = subscribe to nothing."
|
|
2328
2779
|
)
|
|
2329
2780
|
},
|
|
@@ -2361,10 +2812,10 @@ defineTool({
|
|
|
2361
2812
|
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
2362
2813
|
].join("\n"),
|
|
2363
2814
|
input: {
|
|
2364
|
-
channel_id:
|
|
2365
|
-
name:
|
|
2366
|
-
active:
|
|
2367
|
-
events:
|
|
2815
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
2816
|
+
name: z14.string().min(1).max(128).optional().describe("New label."),
|
|
2817
|
+
active: z14.boolean().optional().describe("false silences without deleting."),
|
|
2818
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
2368
2819
|
},
|
|
2369
2820
|
handler: async (args, ctx) => {
|
|
2370
2821
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2403,7 +2854,7 @@ defineTool({
|
|
|
2403
2854
|
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
2404
2855
|
].join("\n"),
|
|
2405
2856
|
input: {
|
|
2406
|
-
channel_id:
|
|
2857
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2407
2858
|
},
|
|
2408
2859
|
handler: async (args, ctx) => {
|
|
2409
2860
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2430,7 +2881,7 @@ defineTool({
|
|
|
2430
2881
|
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
2431
2882
|
].join("\n"),
|
|
2432
2883
|
input: {
|
|
2433
|
-
channel_id:
|
|
2884
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2434
2885
|
},
|
|
2435
2886
|
handler: async (args, ctx) => {
|
|
2436
2887
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2445,7 +2896,7 @@ defineTool({
|
|
|
2445
2896
|
});
|
|
2446
2897
|
|
|
2447
2898
|
// src/tools/projects.ts
|
|
2448
|
-
import { z as
|
|
2899
|
+
import { z as z15 } from "zod";
|
|
2449
2900
|
var AVAILABLE_REGION_IDS = ["eu-central-1"];
|
|
2450
2901
|
defineTool({
|
|
2451
2902
|
name: "list_projects",
|
|
@@ -2486,9 +2937,9 @@ defineTool({
|
|
|
2486
2937
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
2487
2938
|
].join("\n"),
|
|
2488
2939
|
input: {
|
|
2489
|
-
name:
|
|
2490
|
-
description:
|
|
2491
|
-
region:
|
|
2940
|
+
name: z15.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
2941
|
+
description: z15.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
2942
|
+
region: z15.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
|
|
2492
2943
|
},
|
|
2493
2944
|
handler: async (args, ctx) => {
|
|
2494
2945
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2521,9 +2972,9 @@ defineTool({
|
|
|
2521
2972
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
2522
2973
|
].join("\n"),
|
|
2523
2974
|
input: {
|
|
2524
|
-
project_id:
|
|
2525
|
-
name:
|
|
2526
|
-
description:
|
|
2975
|
+
project_id: z15.string().describe("Project publicId."),
|
|
2976
|
+
name: z15.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
2977
|
+
description: z15.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
2527
2978
|
},
|
|
2528
2979
|
handler: async (args, ctx) => {
|
|
2529
2980
|
if (args.name === void 0 && args.description === void 0) {
|
|
@@ -2557,7 +3008,7 @@ defineTool({
|
|
|
2557
3008
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
2558
3009
|
].join("\n"),
|
|
2559
3010
|
input: {
|
|
2560
|
-
project_id:
|
|
3011
|
+
project_id: z15.string().describe("Project publicId (e.g. prj_abc123).")
|
|
2561
3012
|
},
|
|
2562
3013
|
handler: async (args, ctx) => {
|
|
2563
3014
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2569,7 +3020,7 @@ defineTool({
|
|
|
2569
3020
|
});
|
|
2570
3021
|
|
|
2571
3022
|
// src/tools/resource-links.ts
|
|
2572
|
-
import { z as
|
|
3023
|
+
import { z as z16 } from "zod";
|
|
2573
3024
|
var RESOURCE_LINK_TYPES = [
|
|
2574
3025
|
"database",
|
|
2575
3026
|
"object_storage",
|
|
@@ -2630,7 +3081,7 @@ defineTool({
|
|
|
2630
3081
|
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
2631
3082
|
].join("\n"),
|
|
2632
3083
|
input: {
|
|
2633
|
-
service_id:
|
|
3084
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
2634
3085
|
},
|
|
2635
3086
|
handler: async (args, ctx) => {
|
|
2636
3087
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2667,10 +3118,10 @@ defineTool({
|
|
|
2667
3118
|
'Example: link_resource_to_service({ service_id: "svc_abc", resource_type: "database", resource_id: 42, alias: "APP_DB" }) \u2192 { link: { id: 7, alias: "APP_DB" } }'
|
|
2668
3119
|
].join("\n"),
|
|
2669
3120
|
input: {
|
|
2670
|
-
service_id:
|
|
2671
|
-
resource_type:
|
|
2672
|
-
resource_id:
|
|
2673
|
-
alias:
|
|
3121
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3122
|
+
resource_type: z16.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
3123
|
+
resource_id: z16.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
3124
|
+
alias: z16.string().min(1).max(48).regex(
|
|
2674
3125
|
/^[A-Z][A-Z0-9_]*$/,
|
|
2675
3126
|
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
2676
3127
|
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
@@ -2706,8 +3157,8 @@ defineTool({
|
|
|
2706
3157
|
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
2707
3158
|
].join("\n"),
|
|
2708
3159
|
input: {
|
|
2709
|
-
service_id:
|
|
2710
|
-
link_id:
|
|
3160
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3161
|
+
link_id: z16.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
2711
3162
|
},
|
|
2712
3163
|
handler: async (args, ctx) => {
|
|
2713
3164
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2719,7 +3170,7 @@ defineTool({
|
|
|
2719
3170
|
});
|
|
2720
3171
|
|
|
2721
3172
|
// src/tools/services.ts
|
|
2722
|
-
import { z as
|
|
3173
|
+
import { z as z17 } from "zod";
|
|
2723
3174
|
|
|
2724
3175
|
// src/lib/app-templates.ts
|
|
2725
3176
|
var MCP_APP_TEMPLATES = [
|
|
@@ -2869,6 +3320,46 @@ var MCP_APP_TEMPLATES = [
|
|
|
2869
3320
|
installCommand: "npm install",
|
|
2870
3321
|
startCommand: "node worker.js"
|
|
2871
3322
|
},
|
|
3323
|
+
{
|
|
3324
|
+
id: "uptime-kuma",
|
|
3325
|
+
name: "Uptime Kuma",
|
|
3326
|
+
description: "Self-hosted uptime monitoring and status pages, with its own SQLite store",
|
|
3327
|
+
type: "web_service",
|
|
3328
|
+
dockerImage: "louislam/uptime-kuma:1",
|
|
3329
|
+
port: 3e3
|
|
3330
|
+
},
|
|
3331
|
+
{
|
|
3332
|
+
id: "vaultwarden",
|
|
3333
|
+
name: "Vaultwarden",
|
|
3334
|
+
description: "Self-hosted Bitwarden-compatible password manager (unofficial server)",
|
|
3335
|
+
type: "web_service",
|
|
3336
|
+
dockerImage: "vaultwarden/server:1.32.7-alpine",
|
|
3337
|
+
port: 3e3
|
|
3338
|
+
},
|
|
3339
|
+
{
|
|
3340
|
+
id: "n8n",
|
|
3341
|
+
name: "n8n",
|
|
3342
|
+
description: "Self-hosted workflow automation \u2014 visual editor, 400+ integrations",
|
|
3343
|
+
type: "web_service",
|
|
3344
|
+
dockerImage: "n8nio/n8n:1",
|
|
3345
|
+
port: 5678
|
|
3346
|
+
},
|
|
3347
|
+
{
|
|
3348
|
+
id: "wordpress",
|
|
3349
|
+
name: "WordPress",
|
|
3350
|
+
description: "PHP 8.3 + Apache, with a managed MySQL database (billed separately)",
|
|
3351
|
+
type: "web_service",
|
|
3352
|
+
dockerImage: "wordpress:php8.3-apache",
|
|
3353
|
+
port: 80
|
|
3354
|
+
},
|
|
3355
|
+
{
|
|
3356
|
+
id: "ghost",
|
|
3357
|
+
name: "Ghost",
|
|
3358
|
+
description: "Blogs and newsletters, with a managed MySQL database (billed separately)",
|
|
3359
|
+
type: "web_service",
|
|
3360
|
+
dockerImage: "ghost:5-alpine",
|
|
3361
|
+
port: 2368
|
|
3362
|
+
},
|
|
2872
3363
|
{
|
|
2873
3364
|
id: "cron-cleanup",
|
|
2874
3365
|
name: "Cleanup Cron",
|
|
@@ -2936,11 +3427,11 @@ defineTool({
|
|
|
2936
3427
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
2937
3428
|
].join("\n"),
|
|
2938
3429
|
input: {
|
|
2939
|
-
project_id:
|
|
2940
|
-
environment_id:
|
|
2941
|
-
status:
|
|
2942
|
-
type:
|
|
2943
|
-
dev_environment:
|
|
3430
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
3431
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
3432
|
+
status: z17.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
3433
|
+
type: z17.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
3434
|
+
dev_environment: z17.boolean().optional().describe(
|
|
2944
3435
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
2945
3436
|
)
|
|
2946
3437
|
},
|
|
@@ -2985,6 +3476,8 @@ defineTool({
|
|
|
2985
3476
|
"",
|
|
2986
3477
|
"When to use: the user wants to deploy something new. For a one-command AI dev environment specifically, prefer create_dev_environment (it also attaches the /workspace volume and sets the MCP keys).",
|
|
2987
3478
|
"",
|
|
3479
|
+
"*** For a packaged app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden), start at list_templates and pass template_id. *** Those templates are prebuilt images that only boot with the volume, scratch dirs, uid and companion database the platform attaches from the template id \u2014 the same image passed as a bare docker_image crash-loops on the read-only rootfs, and the fix is a redeploy away rather than an edit away.",
|
|
3480
|
+
"",
|
|
2988
3481
|
'*** NOT for databases. *** If the user wants Postgres, Redis, MySQL, MariaDB or MongoDB, call create_database \u2014 do NOT create a service with docker_image "postgres:16" / "redis:7" / "mongo" / "mysql". A database deployed as a service is unmanaged: no backups, no version upgrades, no HA, no credential rotation, no metrics, no persistent volume, and nothing injects its URL into your app. `docker_image` here is for YOUR application images (or sidecars), not for datastores the platform already manages.',
|
|
2989
3482
|
"",
|
|
2990
3483
|
"Inputs:",
|
|
@@ -2998,6 +3491,8 @@ defineTool({
|
|
|
2998
3491
|
" - cron_schedule (optional): cron expression \u2014 required for cron_job.",
|
|
2999
3492
|
' - publish_path (optional): static-site output dir (e.g. "dist").',
|
|
3000
3493
|
' - runtime (optional): "node" | "bun" | "python" | \u2026 (auto-detected from a repo when omitted).',
|
|
3494
|
+
" - port (optional): the port the container listens on (1\u201365535). A source-built service should omit it \u2014 the platform injects $PORT and expects the app to bind that. Set it for a docker_image whose listen port is fixed by the image (WordPress 80, Ghost 2368, n8n 5678): the platform publishes and health-checks this port and never re-reads what the process actually bound, so an image listening elsewhere fails its first deploy on a container that is perfectly healthy.",
|
|
3495
|
+
" - template_id (optional): create from a quickstart template \u2014 an id from list_templates and nothing else. Everything the template brings (volumes, scratch dirs, uid, generated secrets, companion managed database) is resolved server-side and attached before the first deploy fires; there is no way to send those in the body. For an image template, pass its docker_image and port alongside, exactly as list_templates returns them.",
|
|
3001
3496
|
' - plan (optional): service size (default "micro").',
|
|
3002
3497
|
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
3003
3498
|
" - auto_deploy (optional, default true): trigger the first deploy immediately when a source is present.",
|
|
@@ -3005,26 +3500,33 @@ defineTool({
|
|
|
3005
3500
|
"",
|
|
3006
3501
|
"Returns: { service: Service, deployId: number | null }.",
|
|
3007
3502
|
"",
|
|
3008
|
-
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
|
|
3503
|
+
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }',
|
|
3504
|
+
'Example (one-click app): create_service({ project_id: "prj_abc", name: "blog", type: "web_service", template_id: "wordpress", docker_image: "wordpress:php8.3-apache", port: 80 }) \u2014 the wp-content volume, the www-data uid, the Apache scratch dirs and the managed MySQL come from the template id.'
|
|
3009
3505
|
].join("\n"),
|
|
3010
3506
|
input: {
|
|
3011
|
-
project_id:
|
|
3012
|
-
name:
|
|
3013
|
-
type:
|
|
3014
|
-
docker_image:
|
|
3507
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3508
|
+
name: z17.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
3509
|
+
type: z17.enum(SERVICE_TYPES).describe("Service type."),
|
|
3510
|
+
docker_image: z17.string().max(500).optional().describe(
|
|
3015
3511
|
"Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
|
|
3016
3512
|
),
|
|
3017
|
-
github_repo_id:
|
|
3018
|
-
branch:
|
|
3019
|
-
install_command:
|
|
3020
|
-
build_command:
|
|
3021
|
-
start_command:
|
|
3022
|
-
cron_schedule:
|
|
3023
|
-
publish_path:
|
|
3024
|
-
runtime:
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3513
|
+
github_repo_id: z17.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
3514
|
+
branch: z17.string().max(200).optional().describe('Git branch (default "main").'),
|
|
3515
|
+
install_command: z17.string().max(1e3).optional().describe("Install shell command."),
|
|
3516
|
+
build_command: z17.string().max(1e3).optional().describe("Build shell command."),
|
|
3517
|
+
start_command: z17.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
3518
|
+
cron_schedule: z17.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
3519
|
+
publish_path: z17.string().max(500).optional().describe("Static-site output dir."),
|
|
3520
|
+
runtime: z17.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
3521
|
+
port: z17.number().int().min(1).max(65535).optional().describe(
|
|
3522
|
+
"Listen port, for a prebuilt image whose port is fixed by the image. Omit for a source-built service \u2014 it binds the injected $PORT."
|
|
3523
|
+
),
|
|
3524
|
+
template_id: z17.string().max(64).optional().describe(
|
|
3525
|
+
"Quickstart template id from list_templates. Its volumes, scratch dirs, uid, secrets and companion database are resolved server-side; for an image template also pass its docker_image and port."
|
|
3526
|
+
),
|
|
3527
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
3528
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
3529
|
+
auto_deploy: z17.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
|
|
3028
3530
|
machine: machineInput
|
|
3029
3531
|
},
|
|
3030
3532
|
handler: async (args, ctx) => {
|
|
@@ -3047,6 +3549,8 @@ defineTool({
|
|
|
3047
3549
|
if (args.cron_schedule !== void 0) input.cronSchedule = args.cron_schedule;
|
|
3048
3550
|
if (args.publish_path !== void 0) input.publishPath = args.publish_path;
|
|
3049
3551
|
if (args.runtime !== void 0) input.runtime = args.runtime;
|
|
3552
|
+
if (args.port !== void 0) input.port = args.port;
|
|
3553
|
+
if (args.template_id !== void 0) input.templateId = args.template_id;
|
|
3050
3554
|
if (args.plan !== void 0) input.plan = args.plan;
|
|
3051
3555
|
if (args.auto_deploy !== void 0) input.autoDeploy = args.auto_deploy;
|
|
3052
3556
|
if (args.environment_id !== void 0) {
|
|
@@ -3093,18 +3597,18 @@ defineTool({
|
|
|
3093
3597
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
3094
3598
|
].join("\n"),
|
|
3095
3599
|
input: {
|
|
3096
|
-
project_id:
|
|
3097
|
-
name:
|
|
3098
|
-
plan:
|
|
3600
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3601
|
+
name: z17.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
3602
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
3099
3603
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
3100
3604
|
),
|
|
3101
|
-
disk_gb:
|
|
3102
|
-
hoststack_api_key:
|
|
3103
|
-
poststack_api_key:
|
|
3104
|
-
repo_url:
|
|
3605
|
+
disk_gb: z17.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
|
|
3606
|
+
hoststack_api_key: z17.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
3607
|
+
poststack_api_key: z17.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
3608
|
+
repo_url: z17.string().max(500).optional().describe(
|
|
3105
3609
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
3106
3610
|
),
|
|
3107
|
-
branch:
|
|
3611
|
+
branch: z17.string().max(200).optional().describe("Branch to clone (with repo_url)."),
|
|
3108
3612
|
machine: machineInput
|
|
3109
3613
|
},
|
|
3110
3614
|
handler: async (args, ctx) => {
|
|
@@ -3221,9 +3725,9 @@ defineTool({
|
|
|
3221
3725
|
'Example: spin_up_dev_environment({ service_id: "svc_api" }) \u2192 a dev box running a clone of the api service (repo + env-vars + cloned DB) with a public dev URL.'
|
|
3222
3726
|
].join("\n"),
|
|
3223
3727
|
input: {
|
|
3224
|
-
service_id:
|
|
3225
|
-
include_database_clone:
|
|
3226
|
-
name:
|
|
3728
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
3729
|
+
include_database_clone: z17.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
3730
|
+
name: z17.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
3227
3731
|
},
|
|
3228
3732
|
handler: async (args, ctx) => {
|
|
3229
3733
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3264,7 +3768,7 @@ defineTool({
|
|
|
3264
3768
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
3265
3769
|
].join("\n"),
|
|
3266
3770
|
input: {
|
|
3267
|
-
service_id:
|
|
3771
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
3268
3772
|
},
|
|
3269
3773
|
handler: async (args, ctx) => {
|
|
3270
3774
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3300,8 +3804,8 @@ defineTool({
|
|
|
3300
3804
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
3301
3805
|
].join("\n"),
|
|
3302
3806
|
input: {
|
|
3303
|
-
service_id:
|
|
3304
|
-
size:
|
|
3807
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
3808
|
+
size: z17.enum(SERVICE_PLANS).describe(
|
|
3305
3809
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
3306
3810
|
)
|
|
3307
3811
|
},
|
|
@@ -3355,13 +3859,13 @@ defineTool({
|
|
|
3355
3859
|
name: "list_templates",
|
|
3356
3860
|
category: "services",
|
|
3357
3861
|
description: [
|
|
3358
|
-
"List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset.
|
|
3862
|
+
"List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset. Two kinds live in one list: a stack template (Next.js, Django, SvelteKit, Spring Boot, \u2026) is a pre-filled install/build/start command set you pair with a Git repo, and a one-click app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden) is a prebuilt image that needs no repo at all.",
|
|
3359
3863
|
"",
|
|
3360
|
-
'When to use: whenever asked to deploy a known stack ("deploy my Next.js app", "put this Django project on HostStack"), before hand-writing a start command; and before creating a Dev Box, to confirm the image, /workspace size, plan floor, and companion engines instead of duplicating those constants.',
|
|
3864
|
+
'When to use: whenever asked to deploy a known stack ("deploy my Next.js app", "put this Django project on HostStack"), before hand-writing a start command; whenever asked for a packaged app ("deploy WordPress", "I want a Ghost blog"), before reaching for a bare docker_image; and before creating a Dev Box, to confirm the image, /workspace size, plan floor, and companion engines instead of duplicating those constants.',
|
|
3361
3865
|
"",
|
|
3362
|
-
"Returns: { templates: [{ id, name, description, type, runtime
|
|
3866
|
+
"Returns: { templates: [{ id, name, description, type, runtime?, installCommand?, buildCommand?, startCommand?, publishPath?, cronSchedule?, dockerImage?, port?, requiresTemplateId? }], devBox: { id, name, image, volume: { name, mountPath, sizeGb }, minPlan, createWith, companions, inBox } }. A template WITHOUT `dockerImage` is source-built \u2014 pass its commands to create_service with a `github_repo_id`. A template WITH `dockerImage` is flagged `requiresTemplateId` and must be created as create_service({ template_id, docker_image, port, \u2026 }): the volume, scratch dirs, uid, generated secrets and companion managed database it needs are attached server-side from the id, and the same image sent without it crash-loops on the read-only rootfs. The Dev Box is NOT in `templates`: it is created with create_standalone_dev_environment / create_dev_environment, never create_service. `companions` are SEPARATE managed databases you can attach; `inBox` is what the box already runs on its own (engines via `dev-services`, language runtimes via `dev-runtime`) \u2014 check it before concluding a box cannot run something.",
|
|
3363
3867
|
"",
|
|
3364
|
-
'Example: list_templates() \u2192 { templates: [{ id: "nextjs-ssr", name: "Next.js", type: "web_service", runtime: "node", installCommand: "npm install", buildCommand: "npm run build", startCommand: "npm run start" }, \u2026], devBox: { id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] } }'
|
|
3868
|
+
'Example: list_templates() \u2192 { templates: [{ id: "nextjs-ssr", name: "Next.js", type: "web_service", runtime: "node", installCommand: "npm install", buildCommand: "npm run build", startCommand: "npm run start" }, { id: "wordpress", name: "WordPress", type: "web_service", dockerImage: "wordpress:php8.3-apache", port: 80, requiresTemplateId: true }, \u2026], devBox: { id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] } }'
|
|
3365
3869
|
].join("\n"),
|
|
3366
3870
|
input: {},
|
|
3367
3871
|
handler: async () => {
|
|
@@ -3402,12 +3906,14 @@ defineTool({
|
|
|
3402
3906
|
}
|
|
3403
3907
|
}
|
|
3404
3908
|
};
|
|
3405
|
-
const templates = MCP_APP_TEMPLATES
|
|
3909
|
+
const templates = MCP_APP_TEMPLATES.map(
|
|
3910
|
+
(t) => t.dockerImage ? { ...t, requiresTemplateId: true } : t
|
|
3911
|
+
);
|
|
3406
3912
|
const byType = /* @__PURE__ */ new Map();
|
|
3407
3913
|
for (const t of templates) byType.set(t.type, (byType.get(t.type) ?? 0) + 1);
|
|
3408
3914
|
const breakdown = [...byType].map(([type, n]) => `${n} ${type}`).join(", ");
|
|
3409
3915
|
return respond({
|
|
3410
|
-
summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id
|
|
3916
|
+
summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id, then either pass its commands to create_service with a github_repo_id, or, if it has a dockerImage, pass template_id + docker_image + port. Plus the Dev Box preset (image ${DEV_ENV_IMAGE}, /workspace ${DEV_ENV_VOLUME.sizeGb} GB, floor "${DEV_ENV_MIN_SIZE}"), which is created with create_standalone_dev_environment, not create_service.`,
|
|
3411
3917
|
data: { templates, devBox }
|
|
3412
3918
|
});
|
|
3413
3919
|
}
|
|
@@ -3442,21 +3948,21 @@ defineTool({
|
|
|
3442
3948
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
3443
3949
|
].join("\n"),
|
|
3444
3950
|
input: {
|
|
3445
|
-
name:
|
|
3951
|
+
name: z17.string().min(1).max(100).optional().describe(
|
|
3446
3952
|
'Dev Box name. Omit to have it named after the source (the repo name, or "dev-box" for a blank one), de-duplicated against existing boxes.'
|
|
3447
3953
|
),
|
|
3448
|
-
source_kind:
|
|
3449
|
-
github_repo_id:
|
|
3450
|
-
clone_url:
|
|
3451
|
-
branch:
|
|
3452
|
-
databases:
|
|
3453
|
-
plan:
|
|
3954
|
+
source_kind: z17.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
3955
|
+
github_repo_id: z17.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
3956
|
+
clone_url: z17.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
3957
|
+
branch: z17.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
3958
|
+
databases: z17.array(z17.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
3959
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
3454
3960
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
3455
3961
|
),
|
|
3456
|
-
agent_accounts:
|
|
3457
|
-
|
|
3458
|
-
provider:
|
|
3459
|
-
account_id:
|
|
3962
|
+
agent_accounts: z17.array(
|
|
3963
|
+
z17.object({
|
|
3964
|
+
provider: z17.enum(["claude", "codex", "opencode"]),
|
|
3965
|
+
account_id: z17.number().int().positive()
|
|
3460
3966
|
})
|
|
3461
3967
|
).max(3).optional().describe(
|
|
3462
3968
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
@@ -3534,7 +4040,7 @@ defineTool({
|
|
|
3534
4040
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
3535
4041
|
].join("\n"),
|
|
3536
4042
|
input: {
|
|
3537
|
-
service_id:
|
|
4043
|
+
service_id: z17.string().describe("Service publicId (e.g. svc_abc123).")
|
|
3538
4044
|
},
|
|
3539
4045
|
handler: async (args, ctx) => {
|
|
3540
4046
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3566,7 +4072,7 @@ defineTool({
|
|
|
3566
4072
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
3567
4073
|
].join("\n"),
|
|
3568
4074
|
input: {
|
|
3569
|
-
service_id:
|
|
4075
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3570
4076
|
},
|
|
3571
4077
|
handler: async (args, ctx) => {
|
|
3572
4078
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3595,9 +4101,9 @@ defineTool({
|
|
|
3595
4101
|
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
3596
4102
|
].join("\n"),
|
|
3597
4103
|
input: {
|
|
3598
|
-
service_id:
|
|
3599
|
-
from:
|
|
3600
|
-
to:
|
|
4104
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4105
|
+
from: z17.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
4106
|
+
to: z17.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
3601
4107
|
},
|
|
3602
4108
|
handler: async (args, ctx) => {
|
|
3603
4109
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3641,8 +4147,8 @@ defineTool({
|
|
|
3641
4147
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
3642
4148
|
].join("\n"),
|
|
3643
4149
|
input: {
|
|
3644
|
-
service_id:
|
|
3645
|
-
name:
|
|
4150
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4151
|
+
name: z17.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
3646
4152
|
},
|
|
3647
4153
|
handler: async (args, ctx) => {
|
|
3648
4154
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3691,40 +4197,40 @@ defineTool({
|
|
|
3691
4197
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
3692
4198
|
].join("\n"),
|
|
3693
4199
|
input: {
|
|
3694
|
-
service_id:
|
|
3695
|
-
install_command:
|
|
3696
|
-
build_command:
|
|
3697
|
-
start_command:
|
|
3698
|
-
branch:
|
|
3699
|
-
root_directory:
|
|
3700
|
-
dockerfile_path:
|
|
3701
|
-
auto_deploy:
|
|
3702
|
-
health_check_path:
|
|
3703
|
-
health_check_enabled:
|
|
3704
|
-
health_check_interval:
|
|
3705
|
-
health_check_timeout:
|
|
3706
|
-
health_check_grace_period_sec:
|
|
4200
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4201
|
+
install_command: z17.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
4202
|
+
build_command: z17.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
4203
|
+
start_command: z17.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
4204
|
+
branch: z17.string().optional().describe("Git branch to track."),
|
|
4205
|
+
root_directory: z17.string().optional().describe("Build context root."),
|
|
4206
|
+
dockerfile_path: z17.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
4207
|
+
auto_deploy: z17.boolean().optional().describe("Auto-deploy on push."),
|
|
4208
|
+
health_check_path: z17.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
4209
|
+
health_check_enabled: z17.boolean().optional().describe("Toggle health checking on/off."),
|
|
4210
|
+
health_check_interval: z17.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
4211
|
+
health_check_timeout: z17.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
4212
|
+
health_check_grace_period_sec: z17.number().int().min(1).max(1800).optional().describe(
|
|
3707
4213
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
3708
4214
|
),
|
|
3709
|
-
memory_mb:
|
|
3710
|
-
cpu_shares:
|
|
3711
|
-
disk_size_gb:
|
|
3712
|
-
port:
|
|
3713
|
-
protocol:
|
|
3714
|
-
restart_policy:
|
|
3715
|
-
deploy_strategy:
|
|
4215
|
+
memory_mb: z17.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
4216
|
+
cpu_shares: z17.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
4217
|
+
disk_size_gb: z17.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
4218
|
+
port: z17.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
4219
|
+
protocol: z17.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
4220
|
+
restart_policy: z17.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
4221
|
+
deploy_strategy: z17.enum(["rolling", "recreate"]).optional().describe(
|
|
3716
4222
|
'How a deploy replaces the container. "rolling" (default) = start new, wait for healthy, switch traffic, stop old (zero downtime). "recreate" = stop old first, then start new (brief outage) \u2014 required for a container holding an exclusive lock on a mounted volume, which cannot deploy at all under rolling.'
|
|
3717
4223
|
),
|
|
3718
|
-
pre_deploy_command:
|
|
3719
|
-
instance_count:
|
|
3720
|
-
min_instances:
|
|
3721
|
-
max_instances:
|
|
3722
|
-
scale_cpu_threshold:
|
|
3723
|
-
scale_memory_threshold:
|
|
3724
|
-
log_filter_rules:
|
|
3725
|
-
|
|
3726
|
-
pattern:
|
|
3727
|
-
action:
|
|
4224
|
+
pre_deploy_command: z17.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
4225
|
+
instance_count: z17.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
4226
|
+
min_instances: z17.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
4227
|
+
max_instances: z17.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
4228
|
+
scale_cpu_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
4229
|
+
scale_memory_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
4230
|
+
log_filter_rules: z17.array(
|
|
4231
|
+
z17.object({
|
|
4232
|
+
pattern: z17.string().min(1).max(200),
|
|
4233
|
+
action: z17.enum(["drop", "downgrade"])
|
|
3728
4234
|
})
|
|
3729
4235
|
).max(50).optional().describe(
|
|
3730
4236
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -3821,7 +4327,7 @@ defineTool({
|
|
|
3821
4327
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3822
4328
|
].join("\n"),
|
|
3823
4329
|
input: {
|
|
3824
|
-
service_id:
|
|
4330
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3825
4331
|
},
|
|
3826
4332
|
handler: async (args, ctx) => {
|
|
3827
4333
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3845,7 +4351,7 @@ defineTool({
|
|
|
3845
4351
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3846
4352
|
].join("\n"),
|
|
3847
4353
|
input: {
|
|
3848
|
-
service_id:
|
|
4354
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3849
4355
|
},
|
|
3850
4356
|
handler: async (args, ctx) => {
|
|
3851
4357
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3871,7 +4377,7 @@ defineTool({
|
|
|
3871
4377
|
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
3872
4378
|
].join("\n"),
|
|
3873
4379
|
input: {
|
|
3874
|
-
service_id:
|
|
4380
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3875
4381
|
},
|
|
3876
4382
|
handler: async (args, ctx) => {
|
|
3877
4383
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3908,16 +4414,16 @@ defineTool({
|
|
|
3908
4414
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
3909
4415
|
].join("\n"),
|
|
3910
4416
|
input: {
|
|
3911
|
-
service_id:
|
|
3912
|
-
lines:
|
|
3913
|
-
since:
|
|
3914
|
-
until:
|
|
3915
|
-
stream:
|
|
3916
|
-
level:
|
|
4417
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4418
|
+
lines: z17.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
4419
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4420
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4421
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4422
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
3917
4423
|
"Filter by structured JSON log level (pino/bunyan/severity). Falls back to a stream-alias hint for plain-text logs (info/debug\u2192stdout, warn/error/fatal\u2192stderr)."
|
|
3918
4424
|
),
|
|
3919
|
-
search:
|
|
3920
|
-
count_only:
|
|
4425
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4426
|
+
count_only: z17.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
3921
4427
|
},
|
|
3922
4428
|
handler: async (args, ctx) => {
|
|
3923
4429
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3966,14 +4472,14 @@ defineTool({
|
|
|
3966
4472
|
'Example: get_service_logs_bulk({ service_ids: ["svc_api", "svc_worker"], level: "error", since: "-15m", count_only: true }) \u2192 { results: { svc_api: { count: 0 }, svc_worker: { count: 12 } } }.'
|
|
3967
4473
|
].join("\n"),
|
|
3968
4474
|
input: {
|
|
3969
|
-
service_ids:
|
|
3970
|
-
lines_per_service:
|
|
3971
|
-
since:
|
|
3972
|
-
until:
|
|
3973
|
-
stream:
|
|
3974
|
-
level:
|
|
3975
|
-
search:
|
|
3976
|
-
count_only:
|
|
4475
|
+
service_ids: z17.array(z17.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
4476
|
+
lines_per_service: z17.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
4477
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4478
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4479
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4480
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
4481
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4482
|
+
count_only: z17.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
3977
4483
|
},
|
|
3978
4484
|
handler: async (args, ctx) => {
|
|
3979
4485
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4018,8 +4524,117 @@ defineTool({
|
|
|
4018
4524
|
}
|
|
4019
4525
|
});
|
|
4020
4526
|
|
|
4527
|
+
// src/tools/uptime.ts
|
|
4528
|
+
import { z as z18 } from "zod";
|
|
4529
|
+
var STATUS_MEANING = [
|
|
4530
|
+
"Status vocabulary: `up` answering as expected \xB7 `down` failed the threshold and an alert is open \xB7 `unknown` not probed yet \xB7 `unresolvable` the service has no active domain to request \xB7 `paused` the service is not meant to be answering (suspended, mid-deploy, never deployed) OR it sleeps when idle and probing it would keep it awake."
|
|
4531
|
+
].join("\n");
|
|
4532
|
+
defineTool({
|
|
4533
|
+
name: "get_uptime_check",
|
|
4534
|
+
category: "uptime",
|
|
4535
|
+
description: [
|
|
4536
|
+
"Read a service's uptime check \u2014 HostStack requesting the service's public URL on a schedule and alerting when it stops answering.",
|
|
4537
|
+
"",
|
|
4538
|
+
'When to use: "is this service actually reachable", or before changing a check to see what it currently does. This is NOT the deploy-time health check: that one watches the container from inside the host and stops mattering once a deploy is live. This one runs from the control plane against the public URL, so it also catches DNS, TLS, edge and routing failures \u2014 and a service that accepts the connection and then answers nothing.',
|
|
4539
|
+
"",
|
|
4540
|
+
STATUS_MEANING,
|
|
4541
|
+
"",
|
|
4542
|
+
"Inputs: serviceId (required).",
|
|
4543
|
+
"",
|
|
4544
|
+
'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
|
|
4545
|
+
"",
|
|
4546
|
+
"Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
|
|
4547
|
+
].join("\n"),
|
|
4548
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4549
|
+
handler: async (args, ctx) => {
|
|
4550
|
+
const teamId = await ctx.resolveTeamId();
|
|
4551
|
+
const response = await ctx.api.get(
|
|
4552
|
+
`/api/services/${teamId}/${args.serviceId}/uptime-check`
|
|
4553
|
+
);
|
|
4554
|
+
if (response.check === null) {
|
|
4555
|
+
return respond({
|
|
4556
|
+
summary: "No uptime check on this service \u2014 nothing is watching its public URL.",
|
|
4557
|
+
data: { check: null }
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
4560
|
+
const check = shape(response.check);
|
|
4561
|
+
return respond({
|
|
4562
|
+
summary: `Uptime check is ${String(check["status"])}${check["lastError"] ? ` \u2014 ${String(check["lastError"])}` : ""}.`,
|
|
4563
|
+
data: { check }
|
|
4564
|
+
});
|
|
4565
|
+
}
|
|
4566
|
+
});
|
|
4567
|
+
defineTool({
|
|
4568
|
+
name: "set_uptime_check",
|
|
4569
|
+
category: "uptime",
|
|
4570
|
+
description: [
|
|
4571
|
+
"Create or update a service's uptime check.",
|
|
4572
|
+
"",
|
|
4573
|
+
"When to use: turning monitoring on for a service that has just gone live, or adjusting a check that is too noisy (raise failureThreshold) or too slow to notice (lower intervalSeconds).",
|
|
4574
|
+
"",
|
|
4575
|
+
'Only service types with a public URL can be checked (web services and static sites). A worker, cron job or private service is refused with 400 \u2014 a check on one could only ever report "no domain to check", which reads as a broken feature rather than an inapplicable one.',
|
|
4576
|
+
"",
|
|
4577
|
+
"IMPORTANT: changing the shape of a check RESETS its accumulated state (status, consecutive failures, open alert). A check whose path or expected status just changed has not observed the new check failing, so carrying failures forward would alert about a condition that was never measured. Pass the full shape you want, not a partial edit of an unknown current state \u2014 read it with get_uptime_check first if that matters.",
|
|
4578
|
+
"",
|
|
4579
|
+
"`path` is a path, not a URL: the host comes from the service's primary domain at probe time, so moving the service to a new domain moves the check with it.",
|
|
4580
|
+
"",
|
|
4581
|
+
"Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
|
|
4582
|
+
"",
|
|
4583
|
+
"Redirects are NOT followed: a 301 is an answer, and following one can walk the probe onto a marketing site and report a dead service as healthy. If a redirect is expected, set expectedStatus to it.",
|
|
4584
|
+
"",
|
|
4585
|
+
'Inputs (all optional except serviceId): enabled, path (default "/"), method (GET|HEAD), expectedStatus (default 200), timeoutMs (1000\u201360000, default 10000 \u2014 this is what catches a hung server), intervalSeconds (30\u20133600, default 60), failureThreshold (1\u201310, default 3 \u2014 one failed request is usually a restart, not an outage).',
|
|
4586
|
+
"",
|
|
4587
|
+
"Returns: { check } with the saved check.",
|
|
4588
|
+
"",
|
|
4589
|
+
"Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
4590
|
+
].join("\n"),
|
|
4591
|
+
input: {
|
|
4592
|
+
serviceId: z18.number().int().positive(),
|
|
4593
|
+
enabled: z18.boolean().optional(),
|
|
4594
|
+
path: z18.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
4595
|
+
method: z18.enum(["GET", "HEAD"]).optional(),
|
|
4596
|
+
expectedStatus: z18.number().int().min(100).max(599).optional(),
|
|
4597
|
+
timeoutMs: z18.number().int().min(1e3).max(6e4).optional(),
|
|
4598
|
+
intervalSeconds: z18.number().int().min(30).max(3600).optional(),
|
|
4599
|
+
failureThreshold: z18.number().int().min(1).max(10).optional()
|
|
4600
|
+
},
|
|
4601
|
+
handler: async (args, ctx) => {
|
|
4602
|
+
const teamId = await ctx.resolveTeamId();
|
|
4603
|
+
const { serviceId, ...body } = args;
|
|
4604
|
+
const response = await ctx.api.put(
|
|
4605
|
+
`/api/services/${teamId}/${serviceId}/uptime-check`,
|
|
4606
|
+
body
|
|
4607
|
+
);
|
|
4608
|
+
return respond({
|
|
4609
|
+
summary: `Uptime check saved. It will start reporting within a minute or two.`,
|
|
4610
|
+
data: { check: shape(response.check) }
|
|
4611
|
+
});
|
|
4612
|
+
}
|
|
4613
|
+
});
|
|
4614
|
+
defineTool({
|
|
4615
|
+
name: "delete_uptime_check",
|
|
4616
|
+
category: "uptime",
|
|
4617
|
+
description: [
|
|
4618
|
+
'Stop checking a service. Any open "not answering" alert is left as it stands rather than being silently resolved \u2014 removing the monitor is not evidence the service came back.',
|
|
4619
|
+
"",
|
|
4620
|
+
"When to use: the service is being retired, or monitoring has moved somewhere else. To pause a check without losing its configuration, call set_uptime_check with enabled=false instead.",
|
|
4621
|
+
"",
|
|
4622
|
+
"Inputs: serviceId (required).",
|
|
4623
|
+
"",
|
|
4624
|
+
"Returns: { success: true }.",
|
|
4625
|
+
"",
|
|
4626
|
+
"Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
|
|
4627
|
+
].join("\n"),
|
|
4628
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4629
|
+
handler: async (args, ctx) => {
|
|
4630
|
+
const teamId = await ctx.resolveTeamId();
|
|
4631
|
+
await ctx.api.delete(`/api/services/${teamId}/${args.serviceId}/uptime-check`);
|
|
4632
|
+
return respond({ summary: "Uptime check removed. Nothing is watching this service now." });
|
|
4633
|
+
}
|
|
4634
|
+
});
|
|
4635
|
+
|
|
4021
4636
|
// src/tools/volumes.ts
|
|
4022
|
-
import { z as
|
|
4637
|
+
import { z as z19 } from "zod";
|
|
4023
4638
|
var MIN_VOLUME_SIZE_GB = 10;
|
|
4024
4639
|
defineTool({
|
|
4025
4640
|
name: "list_volumes",
|
|
@@ -4037,7 +4652,7 @@ defineTool({
|
|
|
4037
4652
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
4038
4653
|
].join("\n"),
|
|
4039
4654
|
input: {
|
|
4040
|
-
service_id:
|
|
4655
|
+
service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4041
4656
|
},
|
|
4042
4657
|
handler: async (args, ctx) => {
|
|
4043
4658
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4066,15 +4681,15 @@ defineTool({
|
|
|
4066
4681
|
'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "pending" } }'
|
|
4067
4682
|
].join("\n"),
|
|
4068
4683
|
input: {
|
|
4069
|
-
service_id:
|
|
4070
|
-
name:
|
|
4071
|
-
mount_path:
|
|
4684
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4685
|
+
name: z19.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
4686
|
+
mount_path: z19.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
4072
4687
|
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
4073
4688
|
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
4074
4689
|
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
4075
4690
|
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
4076
4691
|
// size. Reject it at the call instead.
|
|
4077
|
-
size_gb:
|
|
4692
|
+
size_gb: z19.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
4078
4693
|
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
4079
4694
|
)
|
|
4080
4695
|
},
|
|
@@ -4112,10 +4727,10 @@ defineTool({
|
|
|
4112
4727
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
4113
4728
|
].join("\n"),
|
|
4114
4729
|
input: {
|
|
4115
|
-
service_id:
|
|
4116
|
-
volume_id:
|
|
4117
|
-
mount_path:
|
|
4118
|
-
size_gb:
|
|
4730
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4731
|
+
volume_id: z19.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
4732
|
+
mount_path: z19.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
4733
|
+
size_gb: z19.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
|
|
4119
4734
|
},
|
|
4120
4735
|
handler: async (args, ctx) => {
|
|
4121
4736
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4153,8 +4768,8 @@ defineTool({
|
|
|
4153
4768
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
4154
4769
|
].join("\n"),
|
|
4155
4770
|
input: {
|
|
4156
|
-
service_id:
|
|
4157
|
-
volume_id:
|
|
4771
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4772
|
+
volume_id: z19.string().describe("Volume publicId.")
|
|
4158
4773
|
},
|
|
4159
4774
|
handler: async (args, ctx) => {
|
|
4160
4775
|
const teamId = await ctx.resolveTeamId();
|