@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/hoststack-mcp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
8
8
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
9
9
|
|
|
10
10
|
// src/version.ts
|
|
11
|
-
var MCP_VERSION = true ? "0.
|
|
11
|
+
var MCP_VERSION = true ? "0.18.0" : "0.0.0-dev";
|
|
12
12
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
13
13
|
|
|
14
14
|
// src/api-client.ts
|
|
@@ -228,7 +228,7 @@ definePrompt({
|
|
|
228
228
|
const text = `Goal: diagnose the most recent failed deploy on service ${service_id} and propose a fix.
|
|
229
229
|
|
|
230
230
|
Plan (use these tools in order):
|
|
231
|
-
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
|
|
231
|
+
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.
|
|
232
232
|
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.
|
|
233
233
|
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\`.
|
|
234
234
|
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.
|
|
@@ -510,9 +510,9 @@ defineTool({
|
|
|
510
510
|
name: "list_alerts",
|
|
511
511
|
category: "alerts",
|
|
512
512
|
description: [
|
|
513
|
-
"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.",
|
|
513
|
+
"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.",
|
|
514
514
|
"",
|
|
515
|
-
"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.",
|
|
515
|
+
"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.",
|
|
516
516
|
"",
|
|
517
517
|
'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.',
|
|
518
518
|
"",
|
|
@@ -1146,7 +1146,7 @@ defineTool({
|
|
|
1146
1146
|
"Inputs:",
|
|
1147
1147
|
' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
|
|
1148
1148
|
"",
|
|
1149
|
-
'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled),
|
|
1149
|
+
'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".',
|
|
1150
1150
|
"",
|
|
1151
1151
|
'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
|
|
1152
1152
|
].join("\n"),
|
|
@@ -1173,7 +1173,7 @@ defineTool({
|
|
|
1173
1173
|
" - service_id: publicId of the service.",
|
|
1174
1174
|
' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
|
|
1175
1175
|
"",
|
|
1176
|
-
"Returns: { deploy: Deploy } \u2014 full deploy record (status,
|
|
1176
|
+
"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).",
|
|
1177
1177
|
"",
|
|
1178
1178
|
'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
|
|
1179
1179
|
].join("\n"),
|
|
@@ -1393,7 +1393,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1393
1393
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1394
1394
|
if (input.zone_id) {
|
|
1395
1395
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1396
|
-
const match = zones2.find((
|
|
1396
|
+
const match = zones2.find((z20) => z20.publicId === input.zone_id);
|
|
1397
1397
|
if (!match) {
|
|
1398
1398
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1399
1399
|
}
|
|
@@ -1407,7 +1407,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1407
1407
|
const labels = fqdn.split(".");
|
|
1408
1408
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1409
1409
|
const candidate = labels.slice(i).join(".");
|
|
1410
|
-
const match = zones.find((
|
|
1410
|
+
const match = zones.find((z20) => z20.domainName.toLowerCase() === candidate);
|
|
1411
1411
|
if (match && match.status !== "deleting") {
|
|
1412
1412
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1413
1413
|
}
|
|
@@ -2150,6 +2150,437 @@ defineTool({
|
|
|
2150
2150
|
}
|
|
2151
2151
|
});
|
|
2152
2152
|
|
|
2153
|
+
// src/tools/analytics.ts
|
|
2154
|
+
import { z as z12 } from "zod";
|
|
2155
|
+
async function resolveSiteIds(domains, teamId, api) {
|
|
2156
|
+
if (!domains || domains.length === 0) return void 0;
|
|
2157
|
+
const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
|
|
2158
|
+
const ids = [];
|
|
2159
|
+
const missing = [];
|
|
2160
|
+
for (const domain of domains) {
|
|
2161
|
+
const needle = domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2162
|
+
const match = sites.find((s) => s.domain === needle);
|
|
2163
|
+
if (match) ids.push(match.id);
|
|
2164
|
+
else missing.push(domain);
|
|
2165
|
+
}
|
|
2166
|
+
if (missing.length > 0) {
|
|
2167
|
+
throw new Error(
|
|
2168
|
+
`Not tracking ${missing.join(", ")}. Known sites: ${sites.map((s) => s.domain).join(", ") || "none yet"}.`
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
return ids.join(",");
|
|
2172
|
+
}
|
|
2173
|
+
defineTool({
|
|
2174
|
+
name: "list_analytics_sites",
|
|
2175
|
+
category: "analytics",
|
|
2176
|
+
description: [
|
|
2177
|
+
"List the websites this team tracks \u2014 domains, their site keys, retention, and which service (if any) serves each one.",
|
|
2178
|
+
"",
|
|
2179
|
+
"When to use: before any other analytics call, to learn which domains exist; or to fetch the script tag a site needs.",
|
|
2180
|
+
"",
|
|
2181
|
+
"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.",
|
|
2182
|
+
"",
|
|
2183
|
+
"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.",
|
|
2184
|
+
"",
|
|
2185
|
+
'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.',
|
|
2186
|
+
"",
|
|
2187
|
+
"Example: list_analytics_sites({}) \u2192 { sites: [{ id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026', retentionDays: 30, serviceName: 'micci-web' }] }."
|
|
2188
|
+
].join("\n"),
|
|
2189
|
+
input: {},
|
|
2190
|
+
handler: async (_args, ctx) => {
|
|
2191
|
+
const teamId = await ctx.resolveTeamId();
|
|
2192
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/sites`);
|
|
2193
|
+
const items = Array.isArray(response.sites) ? response.sites.map(shape) : [];
|
|
2194
|
+
const summary = items.length === 0 ? "No analytics sites yet. Create one with create_analytics_site." : `Tracking ${items.length} site${items.length === 1 ? "" : "s"}.`;
|
|
2195
|
+
return respond({ summary, data: { sites: items } });
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
defineTool({
|
|
2199
|
+
name: "check_analytics_site",
|
|
2200
|
+
category: "analytics",
|
|
2201
|
+
description: [
|
|
2202
|
+
"Why a site is reporting nothing \u2014 the diagnosis, without having to generate traffic and guess.",
|
|
2203
|
+
"",
|
|
2204
|
+
"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.",
|
|
2205
|
+
"",
|
|
2206
|
+
"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.",
|
|
2207
|
+
"",
|
|
2208
|
+
"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.",
|
|
2209
|
+
"",
|
|
2210
|
+
"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.",
|
|
2211
|
+
"",
|
|
2212
|
+
"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 } }."
|
|
2213
|
+
].join("\n"),
|
|
2214
|
+
input: {
|
|
2215
|
+
domain: z12.string().max(253).describe("Bare hostname of a site this team tracks.")
|
|
2216
|
+
},
|
|
2217
|
+
handler: async (args2, ctx) => {
|
|
2218
|
+
const teamId = await ctx.resolveTeamId();
|
|
2219
|
+
const siteIds = await resolveSiteIds([args2.domain], teamId, ctx.api);
|
|
2220
|
+
const status = await ctx.api.get(
|
|
2221
|
+
`/api/analytics/${teamId}/sites/${siteIds}/status`
|
|
2222
|
+
);
|
|
2223
|
+
return respond({ summary: `${args2.domain}: ${status.headline}`, data: shape(status) });
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
defineTool({
|
|
2227
|
+
name: "get_analytics_summary",
|
|
2228
|
+
category: "analytics",
|
|
2229
|
+
description: [
|
|
2230
|
+
"One row per site: visitors, pageviews, bounce rate, average visit and live visitors, each with the previous period to compare against.",
|
|
2231
|
+
"",
|
|
2232
|
+
'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.',
|
|
2233
|
+
"",
|
|
2234
|
+
"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.",
|
|
2235
|
+
"",
|
|
2236
|
+
"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.",
|
|
2237
|
+
"",
|
|
2238
|
+
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
|
|
2239
|
+
"",
|
|
2240
|
+
"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 }] }."
|
|
2241
|
+
].join("\n"),
|
|
2242
|
+
input: {
|
|
2243
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2244
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks.")
|
|
2245
|
+
},
|
|
2246
|
+
handler: async (args2, ctx) => {
|
|
2247
|
+
const teamId = await ctx.resolveTeamId();
|
|
2248
|
+
const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
|
|
2249
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/summary`, {
|
|
2250
|
+
range: args2.range ?? "7d",
|
|
2251
|
+
...siteIds ? { siteIds } : {}
|
|
2252
|
+
});
|
|
2253
|
+
const sites = Array.isArray(response.sites) ? response.sites : [];
|
|
2254
|
+
const summed = sites.some((s) => s.visitorsAreSummedDailies);
|
|
2255
|
+
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." : ""}`;
|
|
2256
|
+
return respond({ summary, data: { range: response.range, sites: sites.map(shape) } });
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
defineTool({
|
|
2260
|
+
name: "get_analytics_overview",
|
|
2261
|
+
category: "analytics",
|
|
2262
|
+
description: [
|
|
2263
|
+
"The full breakdown for one site (or several): timeseries, top paths, referrers, custom events, browsers, operating systems, languages, screen sizes, campaigns, devices and countries.",
|
|
2264
|
+
"",
|
|
2265
|
+
"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.",
|
|
2266
|
+
"",
|
|
2267
|
+
"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.",
|
|
2268
|
+
"",
|
|
2269
|
+
"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).",
|
|
2270
|
+
"",
|
|
2271
|
+
"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 }."
|
|
2272
|
+
].join("\n"),
|
|
2273
|
+
input: {
|
|
2274
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2275
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks."),
|
|
2276
|
+
filters: z12.record(z12.string(), z12.string().max(200)).optional().describe("Dimension filters. Ignored for ranges past the raw-event window.")
|
|
2277
|
+
},
|
|
2278
|
+
handler: async (args2, ctx) => {
|
|
2279
|
+
const teamId = await ctx.resolveTeamId();
|
|
2280
|
+
const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
|
|
2281
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/overview`, {
|
|
2282
|
+
range: args2.range ?? "7d",
|
|
2283
|
+
...siteIds ? { siteIds } : {},
|
|
2284
|
+
...args2.filters ?? {}
|
|
2285
|
+
});
|
|
2286
|
+
const { current } = response.summary;
|
|
2287
|
+
const caveats = [];
|
|
2288
|
+
if (response.visitorsAreSummedDailies) {
|
|
2289
|
+
caveats.push(
|
|
2290
|
+
"visitors are daily uniques summed (this range is past the raw-event window)"
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
if (args2.filters && Object.keys(args2.filters).length > 0 && !response.filtersSupported) {
|
|
2294
|
+
caveats.push(
|
|
2295
|
+
"the filters you passed were NOT applied \u2014 they only work on shorter ranges"
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
const summary = `${current.pageviews.toLocaleString()} pageviews from ${current.visitors.toLocaleString()} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
|
|
2299
|
+
return respond({ summary, data: response });
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
defineTool({
|
|
2303
|
+
name: "create_analytics_site",
|
|
2304
|
+
category: "analytics",
|
|
2305
|
+
description: [
|
|
2306
|
+
"Start tracking a domain, and return the script tag to paste into its <head>.",
|
|
2307
|
+
"",
|
|
2308
|
+
"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.",
|
|
2309
|
+
"",
|
|
2310
|
+
"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.",
|
|
2311
|
+
"",
|
|
2312
|
+
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
|
|
2313
|
+
"",
|
|
2314
|
+
`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>' }.`
|
|
2315
|
+
].join("\n"),
|
|
2316
|
+
input: {
|
|
2317
|
+
domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
|
|
2318
|
+
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
|
|
2319
|
+
},
|
|
2320
|
+
handler: async (args2, ctx) => {
|
|
2321
|
+
const teamId = await ctx.resolveTeamId();
|
|
2322
|
+
const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
|
|
2323
|
+
domain: args2.domain,
|
|
2324
|
+
...args2.name ? { name: args2.name } : {}
|
|
2325
|
+
});
|
|
2326
|
+
const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
|
|
2327
|
+
return respond({
|
|
2328
|
+
summary: `Now tracking ${site.domain}. Paste the snippet into its <head> \u2014 nothing is counted until you do.`,
|
|
2329
|
+
data: { site: shape(site), snippet }
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
// src/tools/errors.ts
|
|
2335
|
+
import { z as z13 } from "zod";
|
|
2336
|
+
defineTool({
|
|
2337
|
+
name: "list_error_issues",
|
|
2338
|
+
category: "errors",
|
|
2339
|
+
description: [
|
|
2340
|
+
"List error issues for the team \u2014 exceptions the team's own applications reported, grouped by cause rather than listed one per event.",
|
|
2341
|
+
"",
|
|
2342
|
+
'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).',
|
|
2343
|
+
"",
|
|
2344
|
+
'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.',
|
|
2345
|
+
"",
|
|
2346
|
+
"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.",
|
|
2347
|
+
"",
|
|
2348
|
+
'Defaults to status=unresolved, because the list exists to answer "what is broken". Pass status=resolved or status=ignored for the others.',
|
|
2349
|
+
"",
|
|
2350
|
+
"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).",
|
|
2351
|
+
"",
|
|
2352
|
+
'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).',
|
|
2353
|
+
"",
|
|
2354
|
+
"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 }."
|
|
2355
|
+
].join("\n"),
|
|
2356
|
+
input: {
|
|
2357
|
+
serviceId: z13.number().int().positive().optional().describe("Only issues for this service."),
|
|
2358
|
+
status: z13.enum(["unresolved", "resolved", "ignored"]).optional().describe("Default unresolved."),
|
|
2359
|
+
q: z13.string().max(200).optional().describe("Substring match on title or culprit."),
|
|
2360
|
+
limit: z13.number().int().positive().max(200).optional().describe("Default 50, cap 200."),
|
|
2361
|
+
offset: z13.number().int().min(0).optional(),
|
|
2362
|
+
sort: z13.enum(["last_seen", "first_seen", "count"]).optional().describe("Default last_seen.")
|
|
2363
|
+
},
|
|
2364
|
+
handler: async (args2, ctx) => {
|
|
2365
|
+
const teamId = await ctx.resolveTeamId();
|
|
2366
|
+
const params = {};
|
|
2367
|
+
for (const key of ["serviceId", "status", "q", "limit", "offset", "sort"]) {
|
|
2368
|
+
const value = args2[key];
|
|
2369
|
+
if (value !== void 0) params[key] = String(value);
|
|
2370
|
+
}
|
|
2371
|
+
const response = await ctx.api.get(`/api/errors/${teamId}/issues`, params);
|
|
2372
|
+
const items = Array.isArray(response.issues) ? response.issues.map(shape) : [];
|
|
2373
|
+
const summary = items.length === 0 ? `No ${args2.status ?? "unresolved"} error issues${args2.serviceId ? " for that service" : ""}.` : `Returned ${items.length} of ${response.total} ${args2.status ?? "unresolved"} issue${response.total === 1 ? "" : "s"}.`;
|
|
2374
|
+
return respond({ summary, data: { issues: items, total: response.total } });
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
defineTool({
|
|
2378
|
+
name: "get_error_issue",
|
|
2379
|
+
category: "errors",
|
|
2380
|
+
description: [
|
|
2381
|
+
"Fetch one error issue plus its most recent stored occurrences \u2014 the stack traces, request context and releases behind the aggregate.",
|
|
2382
|
+
"",
|
|
2383
|
+
"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.",
|
|
2384
|
+
"",
|
|
2385
|
+
"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.",
|
|
2386
|
+
"",
|
|
2387
|
+
"Each occurrence has stack (raw, as the runtime printed it), context (tenant-supplied JSON, with sensitive keys already redacted), requestId, release, environment and createdAt.",
|
|
2388
|
+
"",
|
|
2389
|
+
"Inputs: issueId (required), occurrences (how many samples, default 5, max 50).",
|
|
2390
|
+
"",
|
|
2391
|
+
"Returns: { issue: {...}, occurrences: Array }.",
|
|
2392
|
+
"",
|
|
2393
|
+
"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' }] }."
|
|
2394
|
+
].join("\n"),
|
|
2395
|
+
input: {
|
|
2396
|
+
issueId: z13.number().int().positive().describe("Numeric issue id from list_error_issues."),
|
|
2397
|
+
occurrences: z13.number().int().positive().max(50).optional().describe("How many samples to include. Default 5.")
|
|
2398
|
+
},
|
|
2399
|
+
handler: async (args2, ctx) => {
|
|
2400
|
+
const teamId = await ctx.resolveTeamId();
|
|
2401
|
+
const [issueRes, occRes] = await Promise.all([
|
|
2402
|
+
ctx.api.get(`/api/errors/${teamId}/issues/${args2.issueId}`),
|
|
2403
|
+
ctx.api.get(
|
|
2404
|
+
`/api/errors/${teamId}/issues/${args2.issueId}/occurrences`,
|
|
2405
|
+
{ limit: String(args2.occurrences ?? 5) }
|
|
2406
|
+
)
|
|
2407
|
+
]);
|
|
2408
|
+
const occurrences = Array.isArray(occRes.occurrences) ? occRes.occurrences.map(shape) : [];
|
|
2409
|
+
const issue = shape(issueRes.issue);
|
|
2410
|
+
return respond({
|
|
2411
|
+
summary: `${String(issue["title"] ?? "Issue")} \u2014 ${String(issue["occurrenceCount"] ?? 0)} occurrence(s), ${occurrences.length} sample(s) available.`,
|
|
2412
|
+
data: { issue, occurrences }
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
defineTool({
|
|
2417
|
+
name: "update_error_issue",
|
|
2418
|
+
category: "errors",
|
|
2419
|
+
description: [
|
|
2420
|
+
"Resolve, ignore or reopen an error issue.",
|
|
2421
|
+
"",
|
|
2422
|
+
"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).",
|
|
2423
|
+
"",
|
|
2424
|
+
"`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.",
|
|
2425
|
+
"",
|
|
2426
|
+
"`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.",
|
|
2427
|
+
"",
|
|
2428
|
+
"`unresolved` reopens it.",
|
|
2429
|
+
"",
|
|
2430
|
+
"Inputs: issueId (required), status (required).",
|
|
2431
|
+
"",
|
|
2432
|
+
"Returns: { issue: {...} } with the updated row.",
|
|
2433
|
+
"",
|
|
2434
|
+
"Example: update_error_issue({ issueId: 12, status: 'resolved' }) \u2192 { issue: { status: 'resolved', resolvedInRelease: 'a91f3c2', \u2026 } }."
|
|
2435
|
+
].join("\n"),
|
|
2436
|
+
input: {
|
|
2437
|
+
issueId: z13.number().int().positive(),
|
|
2438
|
+
status: z13.enum(["unresolved", "resolved", "ignored"])
|
|
2439
|
+
},
|
|
2440
|
+
handler: async (args2, ctx) => {
|
|
2441
|
+
const teamId = await ctx.resolveTeamId();
|
|
2442
|
+
const response = await ctx.api.patch(
|
|
2443
|
+
`/api/errors/${teamId}/issues/${args2.issueId}`,
|
|
2444
|
+
{ status: args2.status }
|
|
2445
|
+
);
|
|
2446
|
+
return respond({
|
|
2447
|
+
summary: `Issue ${args2.issueId} is now ${args2.status}.`,
|
|
2448
|
+
data: { issue: shape(response.issue) }
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
defineTool({
|
|
2453
|
+
name: "fix_error_in_dev_box",
|
|
2454
|
+
category: "errors",
|
|
2455
|
+
description: [
|
|
2456
|
+
"Turn an error issue into a coding-agent task in the project's dev box, with the repository already checked out.",
|
|
2457
|
+
"",
|
|
2458
|
+
"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.",
|
|
2459
|
+
"",
|
|
2460
|
+
"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.",
|
|
2461
|
+
"",
|
|
2462
|
+
"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.",
|
|
2463
|
+
"",
|
|
2464
|
+
"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.",
|
|
2465
|
+
"",
|
|
2466
|
+
"Pressing this twice for one issue returns the existing task (alreadyExisted=true) rather than queueing a duplicate run.",
|
|
2467
|
+
"",
|
|
2468
|
+
"Inputs: issueId (required), serviceId (optional \u2014 pin to a specific dev box; omitted, the project's box is used).",
|
|
2469
|
+
"",
|
|
2470
|
+
"Returns: { task, alreadyExisted, box: { serviceId, name, asleep }, automodeEnabled }.",
|
|
2471
|
+
"",
|
|
2472
|
+
"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 }."
|
|
2473
|
+
].join("\n"),
|
|
2474
|
+
input: {
|
|
2475
|
+
issueId: z13.number().int().positive(),
|
|
2476
|
+
serviceId: z13.number().int().positive().optional().describe("Dev box to pin the task to. Omitted, the project's box is used.")
|
|
2477
|
+
},
|
|
2478
|
+
handler: async (args2, ctx) => {
|
|
2479
|
+
const teamId = await ctx.resolveTeamId();
|
|
2480
|
+
const body = { issueId: args2.issueId };
|
|
2481
|
+
if (args2.serviceId !== void 0) body["serviceId"] = args2.serviceId;
|
|
2482
|
+
const response = await ctx.api.post(
|
|
2483
|
+
`/api/dev-env-tasks/${teamId}/from-issue`,
|
|
2484
|
+
body
|
|
2485
|
+
);
|
|
2486
|
+
const verb = response.alreadyExisted ? "Already queued" : "Queued";
|
|
2487
|
+
const note = response.box.asleep ? " That box is asleep \u2014 resume it before running the task." : "";
|
|
2488
|
+
return respond({
|
|
2489
|
+
summary: `${verb} in dev box "${response.box.name}": ${response.task.title ?? "task"}.${note}`,
|
|
2490
|
+
data: shape(response)
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
defineTool({
|
|
2495
|
+
name: "list_ingest_keys",
|
|
2496
|
+
category: "errors",
|
|
2497
|
+
description: [
|
|
2498
|
+
"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.",
|
|
2499
|
+
"",
|
|
2500
|
+
"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).",
|
|
2501
|
+
"",
|
|
2502
|
+
"Inputs: serviceId (required).",
|
|
2503
|
+
"",
|
|
2504
|
+
"Returns: { keys: Array } with id, publicId, name, prefix, lastUsedAt, createdAt.",
|
|
2505
|
+
"",
|
|
2506
|
+
"Example: list_ingest_keys({ serviceId: 48 }) \u2192 { keys: [{ id: 3, name: 'default', prefix: 'ing_9f3c1ab2', lastUsedAt: '2026-08-21T09:14:00Z' }] }."
|
|
2507
|
+
].join("\n"),
|
|
2508
|
+
input: { serviceId: z13.number().int().positive() },
|
|
2509
|
+
handler: async (args2, ctx) => {
|
|
2510
|
+
const teamId = await ctx.resolveTeamId();
|
|
2511
|
+
const response = await ctx.api.get(
|
|
2512
|
+
`/api/services/${teamId}/${args2.serviceId}/ingest-keys`
|
|
2513
|
+
);
|
|
2514
|
+
const keys = Array.isArray(response.keys) ? response.keys.map(shape) : [];
|
|
2515
|
+
return respond({
|
|
2516
|
+
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.`,
|
|
2517
|
+
data: { keys }
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
defineTool({
|
|
2522
|
+
name: "create_ingest_key",
|
|
2523
|
+
category: "errors",
|
|
2524
|
+
description: [
|
|
2525
|
+
"Mint a write-only error-ingest key for a service, so its application can report exceptions.",
|
|
2526
|
+
"",
|
|
2527
|
+
"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.",
|
|
2528
|
+
"",
|
|
2529
|
+
"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.",
|
|
2530
|
+
"",
|
|
2531
|
+
"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.",
|
|
2532
|
+
"",
|
|
2533
|
+
'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.',
|
|
2534
|
+
"",
|
|
2535
|
+
'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).',
|
|
2536
|
+
"",
|
|
2537
|
+
"Returns: { key: { id, publicId, name, prefix, key } } where `key` is the plaintext.",
|
|
2538
|
+
"",
|
|
2539
|
+
"Example: create_ingest_key({ serviceId: 48 }) \u2192 { id: 4, name: 'default', prefix: 'ing_1b7d40ae', key: 'ing_1b7d40ae\u2026' }."
|
|
2540
|
+
].join("\n"),
|
|
2541
|
+
input: {
|
|
2542
|
+
serviceId: z13.number().int().positive(),
|
|
2543
|
+
name: z13.string().min(1).max(100).optional().describe('Label. Default "default".')
|
|
2544
|
+
},
|
|
2545
|
+
handler: async (args2, ctx) => {
|
|
2546
|
+
const teamId = await ctx.resolveTeamId();
|
|
2547
|
+
const response = await ctx.api.post(
|
|
2548
|
+
`/api/services/${teamId}/${args2.serviceId}/ingest-keys`,
|
|
2549
|
+
{ name: args2.name ?? "default" }
|
|
2550
|
+
);
|
|
2551
|
+
return respond({
|
|
2552
|
+
summary: "Ingest key created. The plaintext key is in the payload and is not retrievable again \u2014 store it now.",
|
|
2553
|
+
data: shape(response.key)
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
defineTool({
|
|
2558
|
+
name: "delete_ingest_key",
|
|
2559
|
+
category: "errors",
|
|
2560
|
+
description: [
|
|
2561
|
+
"Revoke an error-ingest key. It stops working immediately, including on API replicas that had it cached.",
|
|
2562
|
+
"",
|
|
2563
|
+
"When to use: finishing a key rotation, or containing a key that leaked somewhere it should not have.",
|
|
2564
|
+
"",
|
|
2565
|
+
"Anything still posting with it starts getting 401s, so revoke the OLD key after the new one is deployed, not before.",
|
|
2566
|
+
"",
|
|
2567
|
+
"Inputs: serviceId, keyId (both required).",
|
|
2568
|
+
"",
|
|
2569
|
+
"Returns: { success: true }.",
|
|
2570
|
+
"",
|
|
2571
|
+
"Example: delete_ingest_key({ serviceId: 48, keyId: 3 }) \u2192 { success: true }."
|
|
2572
|
+
].join("\n"),
|
|
2573
|
+
input: {
|
|
2574
|
+
serviceId: z13.number().int().positive(),
|
|
2575
|
+
keyId: z13.number().int().positive()
|
|
2576
|
+
},
|
|
2577
|
+
handler: async (args2, ctx) => {
|
|
2578
|
+
const teamId = await ctx.resolveTeamId();
|
|
2579
|
+
await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/ingest-keys/${args2.keyId}`);
|
|
2580
|
+
return respond({ summary: `Ingest key ${args2.keyId} revoked.` });
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2153
2584
|
// src/tools/github.ts
|
|
2154
2585
|
defineTool({
|
|
2155
2586
|
name: "sync_github_repos",
|
|
@@ -2257,7 +2688,7 @@ defineTool({
|
|
|
2257
2688
|
});
|
|
2258
2689
|
|
|
2259
2690
|
// src/tools/notifications.ts
|
|
2260
|
-
import { z as
|
|
2691
|
+
import { z as z14 } from "zod";
|
|
2261
2692
|
var NOTIFICATION_EVENTS = [
|
|
2262
2693
|
"deploy.started",
|
|
2263
2694
|
"deploy.succeeded",
|
|
@@ -2271,9 +2702,25 @@ var NOTIFICATION_EVENTS = [
|
|
|
2271
2702
|
"service.auto_suspended",
|
|
2272
2703
|
"service.acme_cert_failed",
|
|
2273
2704
|
"service.resource_alert",
|
|
2705
|
+
"service.uptime_down",
|
|
2706
|
+
"service.uptime_recovered",
|
|
2707
|
+
"error.issue_new",
|
|
2708
|
+
"error.issue_regressed",
|
|
2274
2709
|
"git.auth_failed",
|
|
2275
2710
|
"cron.execution_failed",
|
|
2276
|
-
"workflow.failed"
|
|
2711
|
+
"workflow.failed",
|
|
2712
|
+
"devenv.agent.needs_input",
|
|
2713
|
+
"devenv.agent.finished",
|
|
2714
|
+
"devenv.task.created",
|
|
2715
|
+
"devenv.task.needs_input",
|
|
2716
|
+
"devenv.task.finished",
|
|
2717
|
+
"database.backup_failed",
|
|
2718
|
+
"database.restore_failed",
|
|
2719
|
+
"machine.offline",
|
|
2720
|
+
"machine.online",
|
|
2721
|
+
"billing.invoice",
|
|
2722
|
+
"billing.payment_failed",
|
|
2723
|
+
"billing.spend_limit"
|
|
2277
2724
|
];
|
|
2278
2725
|
defineTool({
|
|
2279
2726
|
name: "list_notification_channels",
|
|
@@ -2312,17 +2759,21 @@ defineTool({
|
|
|
2312
2759
|
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
2313
2760
|
" - 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).",
|
|
2314
2761
|
"",
|
|
2315
|
-
|
|
2762
|
+
// Rendered from the enum instead of retyped: the hand-written copy of
|
|
2763
|
+
// this sentence went stale the same way the enum did, and a description
|
|
2764
|
+
// that disagrees with the schema teaches the model to send values the
|
|
2765
|
+
// tool then rejects.
|
|
2766
|
+
`Valid events: ${NOTIFICATION_EVENTS.join(", ")}.`,
|
|
2316
2767
|
"",
|
|
2317
2768
|
"Returns: { channel: Channel }.",
|
|
2318
2769
|
"",
|
|
2319
2770
|
"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'] })"
|
|
2320
2771
|
].join("\n"),
|
|
2321
2772
|
input: {
|
|
2322
|
-
type:
|
|
2323
|
-
name:
|
|
2324
|
-
webhook_url:
|
|
2325
|
-
events:
|
|
2773
|
+
type: z14.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
2774
|
+
name: z14.string().min(1).max(128).describe("Human-readable label."),
|
|
2775
|
+
webhook_url: z14.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
2776
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).describe(
|
|
2326
2777
|
"List of events the channel subscribes to. Empty list = subscribe to nothing."
|
|
2327
2778
|
)
|
|
2328
2779
|
},
|
|
@@ -2360,10 +2811,10 @@ defineTool({
|
|
|
2360
2811
|
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
2361
2812
|
].join("\n"),
|
|
2362
2813
|
input: {
|
|
2363
|
-
channel_id:
|
|
2364
|
-
name:
|
|
2365
|
-
active:
|
|
2366
|
-
events:
|
|
2814
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
2815
|
+
name: z14.string().min(1).max(128).optional().describe("New label."),
|
|
2816
|
+
active: z14.boolean().optional().describe("false silences without deleting."),
|
|
2817
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
2367
2818
|
},
|
|
2368
2819
|
handler: async (args2, ctx) => {
|
|
2369
2820
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2402,7 +2853,7 @@ defineTool({
|
|
|
2402
2853
|
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
2403
2854
|
].join("\n"),
|
|
2404
2855
|
input: {
|
|
2405
|
-
channel_id:
|
|
2856
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2406
2857
|
},
|
|
2407
2858
|
handler: async (args2, ctx) => {
|
|
2408
2859
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2429,7 +2880,7 @@ defineTool({
|
|
|
2429
2880
|
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
2430
2881
|
].join("\n"),
|
|
2431
2882
|
input: {
|
|
2432
|
-
channel_id:
|
|
2883
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2433
2884
|
},
|
|
2434
2885
|
handler: async (args2, ctx) => {
|
|
2435
2886
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2444,7 +2895,7 @@ defineTool({
|
|
|
2444
2895
|
});
|
|
2445
2896
|
|
|
2446
2897
|
// src/tools/projects.ts
|
|
2447
|
-
import { z as
|
|
2898
|
+
import { z as z15 } from "zod";
|
|
2448
2899
|
var AVAILABLE_REGION_IDS = ["eu-central-1"];
|
|
2449
2900
|
defineTool({
|
|
2450
2901
|
name: "list_projects",
|
|
@@ -2485,9 +2936,9 @@ defineTool({
|
|
|
2485
2936
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
2486
2937
|
].join("\n"),
|
|
2487
2938
|
input: {
|
|
2488
|
-
name:
|
|
2489
|
-
description:
|
|
2490
|
-
region:
|
|
2939
|
+
name: z15.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
2940
|
+
description: z15.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
2941
|
+
region: z15.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
|
|
2491
2942
|
},
|
|
2492
2943
|
handler: async (args2, ctx) => {
|
|
2493
2944
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2520,9 +2971,9 @@ defineTool({
|
|
|
2520
2971
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
2521
2972
|
].join("\n"),
|
|
2522
2973
|
input: {
|
|
2523
|
-
project_id:
|
|
2524
|
-
name:
|
|
2525
|
-
description:
|
|
2974
|
+
project_id: z15.string().describe("Project publicId."),
|
|
2975
|
+
name: z15.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
2976
|
+
description: z15.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
2526
2977
|
},
|
|
2527
2978
|
handler: async (args2, ctx) => {
|
|
2528
2979
|
if (args2.name === void 0 && args2.description === void 0) {
|
|
@@ -2556,7 +3007,7 @@ defineTool({
|
|
|
2556
3007
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
2557
3008
|
].join("\n"),
|
|
2558
3009
|
input: {
|
|
2559
|
-
project_id:
|
|
3010
|
+
project_id: z15.string().describe("Project publicId (e.g. prj_abc123).")
|
|
2560
3011
|
},
|
|
2561
3012
|
handler: async (args2, ctx) => {
|
|
2562
3013
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2568,7 +3019,7 @@ defineTool({
|
|
|
2568
3019
|
});
|
|
2569
3020
|
|
|
2570
3021
|
// src/tools/resource-links.ts
|
|
2571
|
-
import { z as
|
|
3022
|
+
import { z as z16 } from "zod";
|
|
2572
3023
|
var RESOURCE_LINK_TYPES = [
|
|
2573
3024
|
"database",
|
|
2574
3025
|
"object_storage",
|
|
@@ -2629,7 +3080,7 @@ defineTool({
|
|
|
2629
3080
|
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
2630
3081
|
].join("\n"),
|
|
2631
3082
|
input: {
|
|
2632
|
-
service_id:
|
|
3083
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
2633
3084
|
},
|
|
2634
3085
|
handler: async (args2, ctx) => {
|
|
2635
3086
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2666,10 +3117,10 @@ defineTool({
|
|
|
2666
3117
|
'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" } }'
|
|
2667
3118
|
].join("\n"),
|
|
2668
3119
|
input: {
|
|
2669
|
-
service_id:
|
|
2670
|
-
resource_type:
|
|
2671
|
-
resource_id:
|
|
2672
|
-
alias:
|
|
3120
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3121
|
+
resource_type: z16.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
3122
|
+
resource_id: z16.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
3123
|
+
alias: z16.string().min(1).max(48).regex(
|
|
2673
3124
|
/^[A-Z][A-Z0-9_]*$/,
|
|
2674
3125
|
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
2675
3126
|
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
@@ -2705,8 +3156,8 @@ defineTool({
|
|
|
2705
3156
|
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
2706
3157
|
].join("\n"),
|
|
2707
3158
|
input: {
|
|
2708
|
-
service_id:
|
|
2709
|
-
link_id:
|
|
3159
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3160
|
+
link_id: z16.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
2710
3161
|
},
|
|
2711
3162
|
handler: async (args2, ctx) => {
|
|
2712
3163
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2718,7 +3169,7 @@ defineTool({
|
|
|
2718
3169
|
});
|
|
2719
3170
|
|
|
2720
3171
|
// src/tools/services.ts
|
|
2721
|
-
import { z as
|
|
3172
|
+
import { z as z17 } from "zod";
|
|
2722
3173
|
|
|
2723
3174
|
// src/lib/app-templates.ts
|
|
2724
3175
|
var MCP_APP_TEMPLATES = [
|
|
@@ -2868,6 +3319,46 @@ var MCP_APP_TEMPLATES = [
|
|
|
2868
3319
|
installCommand: "npm install",
|
|
2869
3320
|
startCommand: "node worker.js"
|
|
2870
3321
|
},
|
|
3322
|
+
{
|
|
3323
|
+
id: "uptime-kuma",
|
|
3324
|
+
name: "Uptime Kuma",
|
|
3325
|
+
description: "Self-hosted uptime monitoring and status pages, with its own SQLite store",
|
|
3326
|
+
type: "web_service",
|
|
3327
|
+
dockerImage: "louislam/uptime-kuma:1",
|
|
3328
|
+
port: 3e3
|
|
3329
|
+
},
|
|
3330
|
+
{
|
|
3331
|
+
id: "vaultwarden",
|
|
3332
|
+
name: "Vaultwarden",
|
|
3333
|
+
description: "Self-hosted Bitwarden-compatible password manager (unofficial server)",
|
|
3334
|
+
type: "web_service",
|
|
3335
|
+
dockerImage: "vaultwarden/server:1.32.7-alpine",
|
|
3336
|
+
port: 3e3
|
|
3337
|
+
},
|
|
3338
|
+
{
|
|
3339
|
+
id: "n8n",
|
|
3340
|
+
name: "n8n",
|
|
3341
|
+
description: "Self-hosted workflow automation \u2014 visual editor, 400+ integrations",
|
|
3342
|
+
type: "web_service",
|
|
3343
|
+
dockerImage: "n8nio/n8n:1",
|
|
3344
|
+
port: 5678
|
|
3345
|
+
},
|
|
3346
|
+
{
|
|
3347
|
+
id: "wordpress",
|
|
3348
|
+
name: "WordPress",
|
|
3349
|
+
description: "PHP 8.3 + Apache, with a managed MySQL database (billed separately)",
|
|
3350
|
+
type: "web_service",
|
|
3351
|
+
dockerImage: "wordpress:php8.3-apache",
|
|
3352
|
+
port: 80
|
|
3353
|
+
},
|
|
3354
|
+
{
|
|
3355
|
+
id: "ghost",
|
|
3356
|
+
name: "Ghost",
|
|
3357
|
+
description: "Blogs and newsletters, with a managed MySQL database (billed separately)",
|
|
3358
|
+
type: "web_service",
|
|
3359
|
+
dockerImage: "ghost:5-alpine",
|
|
3360
|
+
port: 2368
|
|
3361
|
+
},
|
|
2871
3362
|
{
|
|
2872
3363
|
id: "cron-cleanup",
|
|
2873
3364
|
name: "Cleanup Cron",
|
|
@@ -2935,11 +3426,11 @@ defineTool({
|
|
|
2935
3426
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
2936
3427
|
].join("\n"),
|
|
2937
3428
|
input: {
|
|
2938
|
-
project_id:
|
|
2939
|
-
environment_id:
|
|
2940
|
-
status:
|
|
2941
|
-
type:
|
|
2942
|
-
dev_environment:
|
|
3429
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
3430
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
3431
|
+
status: z17.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
3432
|
+
type: z17.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
3433
|
+
dev_environment: z17.boolean().optional().describe(
|
|
2943
3434
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
2944
3435
|
)
|
|
2945
3436
|
},
|
|
@@ -2984,6 +3475,8 @@ defineTool({
|
|
|
2984
3475
|
"",
|
|
2985
3476
|
"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).",
|
|
2986
3477
|
"",
|
|
3478
|
+
"*** 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.",
|
|
3479
|
+
"",
|
|
2987
3480
|
'*** 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.',
|
|
2988
3481
|
"",
|
|
2989
3482
|
"Inputs:",
|
|
@@ -2997,6 +3490,8 @@ defineTool({
|
|
|
2997
3490
|
" - cron_schedule (optional): cron expression \u2014 required for cron_job.",
|
|
2998
3491
|
' - publish_path (optional): static-site output dir (e.g. "dist").',
|
|
2999
3492
|
' - runtime (optional): "node" | "bun" | "python" | \u2026 (auto-detected from a repo when omitted).',
|
|
3493
|
+
" - 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.",
|
|
3494
|
+
" - 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.",
|
|
3000
3495
|
' - plan (optional): service size (default "micro").',
|
|
3001
3496
|
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
3002
3497
|
" - auto_deploy (optional, default true): trigger the first deploy immediately when a source is present.",
|
|
@@ -3004,26 +3499,33 @@ defineTool({
|
|
|
3004
3499
|
"",
|
|
3005
3500
|
"Returns: { service: Service, deployId: number | null }.",
|
|
3006
3501
|
"",
|
|
3007
|
-
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
|
|
3502
|
+
'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 (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.'
|
|
3008
3504
|
].join("\n"),
|
|
3009
3505
|
input: {
|
|
3010
|
-
project_id:
|
|
3011
|
-
name:
|
|
3012
|
-
type:
|
|
3013
|
-
docker_image:
|
|
3506
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3507
|
+
name: z17.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
3508
|
+
type: z17.enum(SERVICE_TYPES).describe("Service type."),
|
|
3509
|
+
docker_image: z17.string().max(500).optional().describe(
|
|
3014
3510
|
"Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
|
|
3015
3511
|
),
|
|
3016
|
-
github_repo_id:
|
|
3017
|
-
branch:
|
|
3018
|
-
install_command:
|
|
3019
|
-
build_command:
|
|
3020
|
-
start_command:
|
|
3021
|
-
cron_schedule:
|
|
3022
|
-
publish_path:
|
|
3023
|
-
runtime:
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3512
|
+
github_repo_id: z17.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
3513
|
+
branch: z17.string().max(200).optional().describe('Git branch (default "main").'),
|
|
3514
|
+
install_command: z17.string().max(1e3).optional().describe("Install shell command."),
|
|
3515
|
+
build_command: z17.string().max(1e3).optional().describe("Build shell command."),
|
|
3516
|
+
start_command: z17.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
3517
|
+
cron_schedule: z17.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
3518
|
+
publish_path: z17.string().max(500).optional().describe("Static-site output dir."),
|
|
3519
|
+
runtime: z17.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
3520
|
+
port: z17.number().int().min(1).max(65535).optional().describe(
|
|
3521
|
+
"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."
|
|
3522
|
+
),
|
|
3523
|
+
template_id: z17.string().max(64).optional().describe(
|
|
3524
|
+
"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."
|
|
3525
|
+
),
|
|
3526
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
3527
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
3528
|
+
auto_deploy: z17.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
|
|
3027
3529
|
machine: machineInput
|
|
3028
3530
|
},
|
|
3029
3531
|
handler: async (args2, ctx) => {
|
|
@@ -3046,6 +3548,8 @@ defineTool({
|
|
|
3046
3548
|
if (args2.cron_schedule !== void 0) input.cronSchedule = args2.cron_schedule;
|
|
3047
3549
|
if (args2.publish_path !== void 0) input.publishPath = args2.publish_path;
|
|
3048
3550
|
if (args2.runtime !== void 0) input.runtime = args2.runtime;
|
|
3551
|
+
if (args2.port !== void 0) input.port = args2.port;
|
|
3552
|
+
if (args2.template_id !== void 0) input.templateId = args2.template_id;
|
|
3049
3553
|
if (args2.plan !== void 0) input.plan = args2.plan;
|
|
3050
3554
|
if (args2.auto_deploy !== void 0) input.autoDeploy = args2.auto_deploy;
|
|
3051
3555
|
if (args2.environment_id !== void 0) {
|
|
@@ -3092,18 +3596,18 @@ defineTool({
|
|
|
3092
3596
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
3093
3597
|
].join("\n"),
|
|
3094
3598
|
input: {
|
|
3095
|
-
project_id:
|
|
3096
|
-
name:
|
|
3097
|
-
plan:
|
|
3599
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3600
|
+
name: z17.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
3601
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
3098
3602
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
3099
3603
|
),
|
|
3100
|
-
disk_gb:
|
|
3101
|
-
hoststack_api_key:
|
|
3102
|
-
poststack_api_key:
|
|
3103
|
-
repo_url:
|
|
3604
|
+
disk_gb: z17.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
|
|
3605
|
+
hoststack_api_key: z17.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
3606
|
+
poststack_api_key: z17.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
3607
|
+
repo_url: z17.string().max(500).optional().describe(
|
|
3104
3608
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
3105
3609
|
),
|
|
3106
|
-
branch:
|
|
3610
|
+
branch: z17.string().max(200).optional().describe("Branch to clone (with repo_url)."),
|
|
3107
3611
|
machine: machineInput
|
|
3108
3612
|
},
|
|
3109
3613
|
handler: async (args2, ctx) => {
|
|
@@ -3220,9 +3724,9 @@ defineTool({
|
|
|
3220
3724
|
'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.'
|
|
3221
3725
|
].join("\n"),
|
|
3222
3726
|
input: {
|
|
3223
|
-
service_id:
|
|
3224
|
-
include_database_clone:
|
|
3225
|
-
name:
|
|
3727
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
3728
|
+
include_database_clone: z17.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
3729
|
+
name: z17.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
3226
3730
|
},
|
|
3227
3731
|
handler: async (args2, ctx) => {
|
|
3228
3732
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3263,7 +3767,7 @@ defineTool({
|
|
|
3263
3767
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
3264
3768
|
].join("\n"),
|
|
3265
3769
|
input: {
|
|
3266
|
-
service_id:
|
|
3770
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
3267
3771
|
},
|
|
3268
3772
|
handler: async (args2, ctx) => {
|
|
3269
3773
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3299,8 +3803,8 @@ defineTool({
|
|
|
3299
3803
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
3300
3804
|
].join("\n"),
|
|
3301
3805
|
input: {
|
|
3302
|
-
service_id:
|
|
3303
|
-
size:
|
|
3806
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
3807
|
+
size: z17.enum(SERVICE_PLANS).describe(
|
|
3304
3808
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
3305
3809
|
)
|
|
3306
3810
|
},
|
|
@@ -3354,13 +3858,13 @@ defineTool({
|
|
|
3354
3858
|
name: "list_templates",
|
|
3355
3859
|
category: "services",
|
|
3356
3860
|
description: [
|
|
3357
|
-
"List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset.
|
|
3861
|
+
"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.",
|
|
3358
3862
|
"",
|
|
3359
|
-
'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.',
|
|
3863
|
+
'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.',
|
|
3360
3864
|
"",
|
|
3361
|
-
"Returns: { templates: [{ id, name, description, type, runtime
|
|
3865
|
+
"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.",
|
|
3362
3866
|
"",
|
|
3363
|
-
'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"] } }'
|
|
3867
|
+
'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"] } }'
|
|
3364
3868
|
].join("\n"),
|
|
3365
3869
|
input: {},
|
|
3366
3870
|
handler: async () => {
|
|
@@ -3401,12 +3905,14 @@ defineTool({
|
|
|
3401
3905
|
}
|
|
3402
3906
|
}
|
|
3403
3907
|
};
|
|
3404
|
-
const templates = MCP_APP_TEMPLATES
|
|
3908
|
+
const templates = MCP_APP_TEMPLATES.map(
|
|
3909
|
+
(t) => t.dockerImage ? { ...t, requiresTemplateId: true } : t
|
|
3910
|
+
);
|
|
3405
3911
|
const byType = /* @__PURE__ */ new Map();
|
|
3406
3912
|
for (const t of templates) byType.set(t.type, (byType.get(t.type) ?? 0) + 1);
|
|
3407
3913
|
const breakdown = [...byType].map(([type, n]) => `${n} ${type}`).join(", ");
|
|
3408
3914
|
return respond({
|
|
3409
|
-
summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id
|
|
3915
|
+
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.`,
|
|
3410
3916
|
data: { templates, devBox }
|
|
3411
3917
|
});
|
|
3412
3918
|
}
|
|
@@ -3441,21 +3947,21 @@ defineTool({
|
|
|
3441
3947
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
3442
3948
|
].join("\n"),
|
|
3443
3949
|
input: {
|
|
3444
|
-
name:
|
|
3950
|
+
name: z17.string().min(1).max(100).optional().describe(
|
|
3445
3951
|
'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.'
|
|
3446
3952
|
),
|
|
3447
|
-
source_kind:
|
|
3448
|
-
github_repo_id:
|
|
3449
|
-
clone_url:
|
|
3450
|
-
branch:
|
|
3451
|
-
databases:
|
|
3452
|
-
plan:
|
|
3953
|
+
source_kind: z17.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
3954
|
+
github_repo_id: z17.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
3955
|
+
clone_url: z17.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
3956
|
+
branch: z17.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
3957
|
+
databases: z17.array(z17.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
3958
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
3453
3959
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
3454
3960
|
),
|
|
3455
|
-
agent_accounts:
|
|
3456
|
-
|
|
3457
|
-
provider:
|
|
3458
|
-
account_id:
|
|
3961
|
+
agent_accounts: z17.array(
|
|
3962
|
+
z17.object({
|
|
3963
|
+
provider: z17.enum(["claude", "codex", "opencode"]),
|
|
3964
|
+
account_id: z17.number().int().positive()
|
|
3459
3965
|
})
|
|
3460
3966
|
).max(3).optional().describe(
|
|
3461
3967
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
@@ -3533,7 +4039,7 @@ defineTool({
|
|
|
3533
4039
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
3534
4040
|
].join("\n"),
|
|
3535
4041
|
input: {
|
|
3536
|
-
service_id:
|
|
4042
|
+
service_id: z17.string().describe("Service publicId (e.g. svc_abc123).")
|
|
3537
4043
|
},
|
|
3538
4044
|
handler: async (args2, ctx) => {
|
|
3539
4045
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3565,7 +4071,7 @@ defineTool({
|
|
|
3565
4071
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
3566
4072
|
].join("\n"),
|
|
3567
4073
|
input: {
|
|
3568
|
-
service_id:
|
|
4074
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3569
4075
|
},
|
|
3570
4076
|
handler: async (args2, ctx) => {
|
|
3571
4077
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3594,9 +4100,9 @@ defineTool({
|
|
|
3594
4100
|
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
3595
4101
|
].join("\n"),
|
|
3596
4102
|
input: {
|
|
3597
|
-
service_id:
|
|
3598
|
-
from:
|
|
3599
|
-
to:
|
|
4103
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4104
|
+
from: z17.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
4105
|
+
to: z17.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
3600
4106
|
},
|
|
3601
4107
|
handler: async (args2, ctx) => {
|
|
3602
4108
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3640,8 +4146,8 @@ defineTool({
|
|
|
3640
4146
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
3641
4147
|
].join("\n"),
|
|
3642
4148
|
input: {
|
|
3643
|
-
service_id:
|
|
3644
|
-
name:
|
|
4149
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4150
|
+
name: z17.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
3645
4151
|
},
|
|
3646
4152
|
handler: async (args2, ctx) => {
|
|
3647
4153
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3690,40 +4196,40 @@ defineTool({
|
|
|
3690
4196
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
3691
4197
|
].join("\n"),
|
|
3692
4198
|
input: {
|
|
3693
|
-
service_id:
|
|
3694
|
-
install_command:
|
|
3695
|
-
build_command:
|
|
3696
|
-
start_command:
|
|
3697
|
-
branch:
|
|
3698
|
-
root_directory:
|
|
3699
|
-
dockerfile_path:
|
|
3700
|
-
auto_deploy:
|
|
3701
|
-
health_check_path:
|
|
3702
|
-
health_check_enabled:
|
|
3703
|
-
health_check_interval:
|
|
3704
|
-
health_check_timeout:
|
|
3705
|
-
health_check_grace_period_sec:
|
|
4199
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4200
|
+
install_command: z17.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
4201
|
+
build_command: z17.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
4202
|
+
start_command: z17.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
4203
|
+
branch: z17.string().optional().describe("Git branch to track."),
|
|
4204
|
+
root_directory: z17.string().optional().describe("Build context root."),
|
|
4205
|
+
dockerfile_path: z17.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
4206
|
+
auto_deploy: z17.boolean().optional().describe("Auto-deploy on push."),
|
|
4207
|
+
health_check_path: z17.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
4208
|
+
health_check_enabled: z17.boolean().optional().describe("Toggle health checking on/off."),
|
|
4209
|
+
health_check_interval: z17.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
4210
|
+
health_check_timeout: z17.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
4211
|
+
health_check_grace_period_sec: z17.number().int().min(1).max(1800).optional().describe(
|
|
3706
4212
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
3707
4213
|
),
|
|
3708
|
-
memory_mb:
|
|
3709
|
-
cpu_shares:
|
|
3710
|
-
disk_size_gb:
|
|
3711
|
-
port:
|
|
3712
|
-
protocol:
|
|
3713
|
-
restart_policy:
|
|
3714
|
-
deploy_strategy:
|
|
4214
|
+
memory_mb: z17.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
4215
|
+
cpu_shares: z17.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
4216
|
+
disk_size_gb: z17.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
4217
|
+
port: z17.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
4218
|
+
protocol: z17.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
4219
|
+
restart_policy: z17.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
4220
|
+
deploy_strategy: z17.enum(["rolling", "recreate"]).optional().describe(
|
|
3715
4221
|
'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.'
|
|
3716
4222
|
),
|
|
3717
|
-
pre_deploy_command:
|
|
3718
|
-
instance_count:
|
|
3719
|
-
min_instances:
|
|
3720
|
-
max_instances:
|
|
3721
|
-
scale_cpu_threshold:
|
|
3722
|
-
scale_memory_threshold:
|
|
3723
|
-
log_filter_rules:
|
|
3724
|
-
|
|
3725
|
-
pattern:
|
|
3726
|
-
action:
|
|
4223
|
+
pre_deploy_command: z17.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
4224
|
+
instance_count: z17.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
4225
|
+
min_instances: z17.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
4226
|
+
max_instances: z17.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
4227
|
+
scale_cpu_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
4228
|
+
scale_memory_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
4229
|
+
log_filter_rules: z17.array(
|
|
4230
|
+
z17.object({
|
|
4231
|
+
pattern: z17.string().min(1).max(200),
|
|
4232
|
+
action: z17.enum(["drop", "downgrade"])
|
|
3727
4233
|
})
|
|
3728
4234
|
).max(50).optional().describe(
|
|
3729
4235
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -3820,7 +4326,7 @@ defineTool({
|
|
|
3820
4326
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3821
4327
|
].join("\n"),
|
|
3822
4328
|
input: {
|
|
3823
|
-
service_id:
|
|
4329
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3824
4330
|
},
|
|
3825
4331
|
handler: async (args2, ctx) => {
|
|
3826
4332
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3844,7 +4350,7 @@ defineTool({
|
|
|
3844
4350
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3845
4351
|
].join("\n"),
|
|
3846
4352
|
input: {
|
|
3847
|
-
service_id:
|
|
4353
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3848
4354
|
},
|
|
3849
4355
|
handler: async (args2, ctx) => {
|
|
3850
4356
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3870,7 +4376,7 @@ defineTool({
|
|
|
3870
4376
|
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
3871
4377
|
].join("\n"),
|
|
3872
4378
|
input: {
|
|
3873
|
-
service_id:
|
|
4379
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3874
4380
|
},
|
|
3875
4381
|
handler: async (args2, ctx) => {
|
|
3876
4382
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3907,16 +4413,16 @@ defineTool({
|
|
|
3907
4413
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
3908
4414
|
].join("\n"),
|
|
3909
4415
|
input: {
|
|
3910
|
-
service_id:
|
|
3911
|
-
lines:
|
|
3912
|
-
since:
|
|
3913
|
-
until:
|
|
3914
|
-
stream:
|
|
3915
|
-
level:
|
|
4416
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4417
|
+
lines: z17.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
4418
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4419
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4420
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4421
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
3916
4422
|
"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)."
|
|
3917
4423
|
),
|
|
3918
|
-
search:
|
|
3919
|
-
count_only:
|
|
4424
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4425
|
+
count_only: z17.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
3920
4426
|
},
|
|
3921
4427
|
handler: async (args2, ctx) => {
|
|
3922
4428
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3965,14 +4471,14 @@ defineTool({
|
|
|
3965
4471
|
'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 } } }.'
|
|
3966
4472
|
].join("\n"),
|
|
3967
4473
|
input: {
|
|
3968
|
-
service_ids:
|
|
3969
|
-
lines_per_service:
|
|
3970
|
-
since:
|
|
3971
|
-
until:
|
|
3972
|
-
stream:
|
|
3973
|
-
level:
|
|
3974
|
-
search:
|
|
3975
|
-
count_only:
|
|
4474
|
+
service_ids: z17.array(z17.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
4475
|
+
lines_per_service: z17.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
4476
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4477
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4478
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4479
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
4480
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4481
|
+
count_only: z17.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
3976
4482
|
},
|
|
3977
4483
|
handler: async (args2, ctx) => {
|
|
3978
4484
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4017,8 +4523,117 @@ defineTool({
|
|
|
4017
4523
|
}
|
|
4018
4524
|
});
|
|
4019
4525
|
|
|
4526
|
+
// src/tools/uptime.ts
|
|
4527
|
+
import { z as z18 } from "zod";
|
|
4528
|
+
var STATUS_MEANING = [
|
|
4529
|
+
"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."
|
|
4530
|
+
].join("\n");
|
|
4531
|
+
defineTool({
|
|
4532
|
+
name: "get_uptime_check",
|
|
4533
|
+
category: "uptime",
|
|
4534
|
+
description: [
|
|
4535
|
+
"Read a service's uptime check \u2014 HostStack requesting the service's public URL on a schedule and alerting when it stops answering.",
|
|
4536
|
+
"",
|
|
4537
|
+
'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.',
|
|
4538
|
+
"",
|
|
4539
|
+
STATUS_MEANING,
|
|
4540
|
+
"",
|
|
4541
|
+
"Inputs: serviceId (required).",
|
|
4542
|
+
"",
|
|
4543
|
+
'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).',
|
|
4544
|
+
"",
|
|
4545
|
+
"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' } }."
|
|
4546
|
+
].join("\n"),
|
|
4547
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4548
|
+
handler: async (args2, ctx) => {
|
|
4549
|
+
const teamId = await ctx.resolveTeamId();
|
|
4550
|
+
const response = await ctx.api.get(
|
|
4551
|
+
`/api/services/${teamId}/${args2.serviceId}/uptime-check`
|
|
4552
|
+
);
|
|
4553
|
+
if (response.check === null) {
|
|
4554
|
+
return respond({
|
|
4555
|
+
summary: "No uptime check on this service \u2014 nothing is watching its public URL.",
|
|
4556
|
+
data: { check: null }
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
const check = shape(response.check);
|
|
4560
|
+
return respond({
|
|
4561
|
+
summary: `Uptime check is ${String(check["status"])}${check["lastError"] ? ` \u2014 ${String(check["lastError"])}` : ""}.`,
|
|
4562
|
+
data: { check }
|
|
4563
|
+
});
|
|
4564
|
+
}
|
|
4565
|
+
});
|
|
4566
|
+
defineTool({
|
|
4567
|
+
name: "set_uptime_check",
|
|
4568
|
+
category: "uptime",
|
|
4569
|
+
description: [
|
|
4570
|
+
"Create or update a service's uptime check.",
|
|
4571
|
+
"",
|
|
4572
|
+
"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).",
|
|
4573
|
+
"",
|
|
4574
|
+
'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.',
|
|
4575
|
+
"",
|
|
4576
|
+
"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.",
|
|
4577
|
+
"",
|
|
4578
|
+
"`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.",
|
|
4579
|
+
"",
|
|
4580
|
+
"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.",
|
|
4581
|
+
"",
|
|
4582
|
+
"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.",
|
|
4583
|
+
"",
|
|
4584
|
+
'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).',
|
|
4585
|
+
"",
|
|
4586
|
+
"Returns: { check } with the saved check.",
|
|
4587
|
+
"",
|
|
4588
|
+
"Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
4589
|
+
].join("\n"),
|
|
4590
|
+
input: {
|
|
4591
|
+
serviceId: z18.number().int().positive(),
|
|
4592
|
+
enabled: z18.boolean().optional(),
|
|
4593
|
+
path: z18.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
4594
|
+
method: z18.enum(["GET", "HEAD"]).optional(),
|
|
4595
|
+
expectedStatus: z18.number().int().min(100).max(599).optional(),
|
|
4596
|
+
timeoutMs: z18.number().int().min(1e3).max(6e4).optional(),
|
|
4597
|
+
intervalSeconds: z18.number().int().min(30).max(3600).optional(),
|
|
4598
|
+
failureThreshold: z18.number().int().min(1).max(10).optional()
|
|
4599
|
+
},
|
|
4600
|
+
handler: async (args2, ctx) => {
|
|
4601
|
+
const teamId = await ctx.resolveTeamId();
|
|
4602
|
+
const { serviceId, ...body } = args2;
|
|
4603
|
+
const response = await ctx.api.put(
|
|
4604
|
+
`/api/services/${teamId}/${serviceId}/uptime-check`,
|
|
4605
|
+
body
|
|
4606
|
+
);
|
|
4607
|
+
return respond({
|
|
4608
|
+
summary: `Uptime check saved. It will start reporting within a minute or two.`,
|
|
4609
|
+
data: { check: shape(response.check) }
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
});
|
|
4613
|
+
defineTool({
|
|
4614
|
+
name: "delete_uptime_check",
|
|
4615
|
+
category: "uptime",
|
|
4616
|
+
description: [
|
|
4617
|
+
'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.',
|
|
4618
|
+
"",
|
|
4619
|
+
"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.",
|
|
4620
|
+
"",
|
|
4621
|
+
"Inputs: serviceId (required).",
|
|
4622
|
+
"",
|
|
4623
|
+
"Returns: { success: true }.",
|
|
4624
|
+
"",
|
|
4625
|
+
"Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
|
|
4626
|
+
].join("\n"),
|
|
4627
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4628
|
+
handler: async (args2, ctx) => {
|
|
4629
|
+
const teamId = await ctx.resolveTeamId();
|
|
4630
|
+
await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/uptime-check`);
|
|
4631
|
+
return respond({ summary: "Uptime check removed. Nothing is watching this service now." });
|
|
4632
|
+
}
|
|
4633
|
+
});
|
|
4634
|
+
|
|
4020
4635
|
// src/tools/volumes.ts
|
|
4021
|
-
import { z as
|
|
4636
|
+
import { z as z19 } from "zod";
|
|
4022
4637
|
var MIN_VOLUME_SIZE_GB = 10;
|
|
4023
4638
|
defineTool({
|
|
4024
4639
|
name: "list_volumes",
|
|
@@ -4036,7 +4651,7 @@ defineTool({
|
|
|
4036
4651
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
4037
4652
|
].join("\n"),
|
|
4038
4653
|
input: {
|
|
4039
|
-
service_id:
|
|
4654
|
+
service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4040
4655
|
},
|
|
4041
4656
|
handler: async (args2, ctx) => {
|
|
4042
4657
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4065,15 +4680,15 @@ defineTool({
|
|
|
4065
4680
|
'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" } }'
|
|
4066
4681
|
].join("\n"),
|
|
4067
4682
|
input: {
|
|
4068
|
-
service_id:
|
|
4069
|
-
name:
|
|
4070
|
-
mount_path:
|
|
4683
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4684
|
+
name: z19.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
4685
|
+
mount_path: z19.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
4071
4686
|
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
4072
4687
|
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
4073
4688
|
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
4074
4689
|
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
4075
4690
|
// size. Reject it at the call instead.
|
|
4076
|
-
size_gb:
|
|
4691
|
+
size_gb: z19.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
4077
4692
|
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
4078
4693
|
)
|
|
4079
4694
|
},
|
|
@@ -4111,10 +4726,10 @@ defineTool({
|
|
|
4111
4726
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
4112
4727
|
].join("\n"),
|
|
4113
4728
|
input: {
|
|
4114
|
-
service_id:
|
|
4115
|
-
volume_id:
|
|
4116
|
-
mount_path:
|
|
4117
|
-
size_gb:
|
|
4729
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4730
|
+
volume_id: z19.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
4731
|
+
mount_path: z19.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
4732
|
+
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).`)
|
|
4118
4733
|
},
|
|
4119
4734
|
handler: async (args2, ctx) => {
|
|
4120
4735
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4152,8 +4767,8 @@ defineTool({
|
|
|
4152
4767
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
4153
4768
|
].join("\n"),
|
|
4154
4769
|
input: {
|
|
4155
|
-
service_id:
|
|
4156
|
-
volume_id:
|
|
4770
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4771
|
+
volume_id: z19.string().describe("Volume publicId.")
|
|
4157
4772
|
},
|
|
4158
4773
|
handler: async (args2, ctx) => {
|
|
4159
4774
|
const teamId = await ctx.resolveTeamId();
|