@hoststack.dev/mcp 0.18.0 → 0.20.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 +5 -3
- package/dist/hoststack-mcp.js +678 -180
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +678 -180
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
4
4
|
|
|
5
5
|
// src/version.ts
|
|
6
|
-
var MCP_VERSION = true ? "0.
|
|
6
|
+
var MCP_VERSION = true ? "0.20.0" : "0.0.0-dev";
|
|
7
7
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
8
8
|
|
|
9
9
|
// src/api-client.ts
|
|
@@ -1394,7 +1394,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1394
1394
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1395
1395
|
if (input.zone_id) {
|
|
1396
1396
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1397
|
-
const match = zones2.find((
|
|
1397
|
+
const match = zones2.find((z22) => z22.publicId === input.zone_id);
|
|
1398
1398
|
if (!match) {
|
|
1399
1399
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1400
1400
|
}
|
|
@@ -1408,7 +1408,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1408
1408
|
const labels = fqdn.split(".");
|
|
1409
1409
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1410
1410
|
const candidate = labels.slice(i).join(".");
|
|
1411
|
-
const match = zones.find((
|
|
1411
|
+
const match = zones.find((z22) => z22.domainName.toLowerCase() === candidate);
|
|
1412
1412
|
if (match && match.status !== "deleting") {
|
|
1413
1413
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1414
1414
|
}
|
|
@@ -1724,9 +1724,11 @@ defineTool({
|
|
|
1724
1724
|
"",
|
|
1725
1725
|
"When to use: enumerate live domains, audit DNS verification status, or find which service a hostname resolves to before troubleshooting routing.",
|
|
1726
1726
|
"",
|
|
1727
|
-
"Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, createdAt.",
|
|
1727
|
+
"Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, isPrimary, createdAt.",
|
|
1728
1728
|
"",
|
|
1729
|
-
|
|
1729
|
+
"`isPrimary` is the one the platform treats as the service's address: what `${service.url}` resolves to, what its uptime check probes, and what the monitoring page shows. When NO domain on a service is primary \u2014 the default, since nothing sets it automatically \u2014 that choice falls to the OLDEST domain on the service, which on a renamed host is usually the retired alias rather than the canonical name. Nominate one with update_domain.",
|
|
1730
|
+
"",
|
|
1731
|
+
'Example: list_domains() \u2192 { items: [{ hostname: "api.example.com", verified: true, sslStatus: "active", isPrimary: false, \u2026 }] }'
|
|
1730
1732
|
].join("\n"),
|
|
1731
1733
|
input: {},
|
|
1732
1734
|
handler: async (_args, ctx) => {
|
|
@@ -1802,6 +1804,54 @@ defineTool({
|
|
|
1802
1804
|
});
|
|
1803
1805
|
}
|
|
1804
1806
|
});
|
|
1807
|
+
defineTool({
|
|
1808
|
+
name: "update_domain",
|
|
1809
|
+
category: "domains",
|
|
1810
|
+
description: [
|
|
1811
|
+
"Change a domain's settings \u2014 most usefully, nominate it as the service's PRIMARY hostname.",
|
|
1812
|
+
"",
|
|
1813
|
+
"When to use: a service answers on more than one hostname and you need to say which one is canonical \u2014 before setting an uptime check on it, after renaming a storefront, or when `${service.url}` is resolving to the wrong name. Also for toggling https on a domain or turning one into a redirect.",
|
|
1814
|
+
"",
|
|
1815
|
+
"WHY PRIMARY MATTERS. A service can answer on several hostnames that behave differently \u2014 the canonical one serves the page, a retired alias 301s to it \u2014 and three parts of the platform have to pick ONE of them: the address a service advertises to itself in `${service.url}`, the host its uptime check probes, and the hostname shown on the monitoring page. All three pick the primary, and fall back to the OLDEST domain when no primary is nominated. So a service with two hostnames and no primary has its uptime check pointed at whichever domain was added first, which may well be the alias that redirects \u2014 and `set_uptime_check` takes a path, never a host, so nominating the primary here is the only way to say which name is checked.",
|
|
1816
|
+
"",
|
|
1817
|
+
"One primary per service: promoting a domain demotes its sibling in the same write, so there is never a pair to choose between.",
|
|
1818
|
+
"",
|
|
1819
|
+
"Inputs:",
|
|
1820
|
+
" - domain_id: publicId of the domain (from list_domains).",
|
|
1821
|
+
" - isPrimary: make this the service's primary hostname.",
|
|
1822
|
+
" - sslEnabled: serve it over https. Applies on the next deploy, which this triggers.",
|
|
1823
|
+
" - redirectTo: send every request to an absolute http(s) URL instead of the service. Pass null to clear.",
|
|
1824
|
+
"",
|
|
1825
|
+
"Returns: { domain } \u2014 the updated row, including isPrimary.",
|
|
1826
|
+
"",
|
|
1827
|
+
'Example: update_domain({ domain_id: "dom_xyz", isPrimary: true }) \u2192 { domain: { hostname: "shop.example.com", isPrimary: true } }'
|
|
1828
|
+
].join("\n"),
|
|
1829
|
+
input: {
|
|
1830
|
+
domain_id: z9.string().describe("Domain publicId."),
|
|
1831
|
+
isPrimary: z9.boolean().optional(),
|
|
1832
|
+
sslEnabled: z9.boolean().optional(),
|
|
1833
|
+
redirectTo: z9.string().max(2e3).nullable().optional().describe("Absolute http(s) URL, or null to clear.")
|
|
1834
|
+
},
|
|
1835
|
+
handler: async (args, ctx) => {
|
|
1836
|
+
const { domain_id: domainId, ...patch } = args;
|
|
1837
|
+
const body = Object.fromEntries(
|
|
1838
|
+
Object.entries(patch).filter(([, v]) => v !== void 0)
|
|
1839
|
+
);
|
|
1840
|
+
if (Object.keys(body).length === 0) {
|
|
1841
|
+
return respond({
|
|
1842
|
+
summary: "Nothing to change \u2014 pass isPrimary, sslEnabled or redirectTo.",
|
|
1843
|
+
data: { ok: false }
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
const teamId = await ctx.resolveTeamId();
|
|
1847
|
+
const response = await ctx.hoststack.domains.update(teamId, domainId, body);
|
|
1848
|
+
const domain = shapeDomain(response.domain);
|
|
1849
|
+
return respond({
|
|
1850
|
+
summary: body.isPrimary === true ? `${String(domain.hostname ?? domainId)} is now the primary hostname for its service \u2014 it is what \`\${service.url}\` resolves to and what the uptime check probes.` : `Updated ${String(domain.hostname ?? domainId)}.`,
|
|
1851
|
+
data: { domain }
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
});
|
|
1805
1855
|
defineTool({
|
|
1806
1856
|
name: "remove_domain",
|
|
1807
1857
|
category: "domains",
|
|
@@ -1869,6 +1919,7 @@ defineTool({
|
|
|
1869
1919
|
" - value: new value (will be encrypted at rest if is_secret=true).",
|
|
1870
1920
|
" - is_secret (optional): true marks the value as secret (masked on read). On create, defaults to true for safety. On update, omitting it leaves the existing flag untouched \u2014 pass it explicitly only when you want to change classification.",
|
|
1871
1921
|
' - target (optional): where the var is injected \u2014 "build", "runtime", or "both". On create, defaults to "both". On update, omitting it leaves the existing target untouched.',
|
|
1922
|
+
' IMPORTANT for build-time values (a frontend VITE_*/NEXT_PUBLIC_*, anything a bundler inlines): "both" and "build" reach the build, but a SECRET var never does on "both" \u2014 build args are baked into image history. Since is_secret defaults to TRUE here, set is_secret:false for a public build-time value or it will silently be missing from the built artifact. Use "build" to force a secret into the build anyway.',
|
|
1872
1923
|
"",
|
|
1873
1924
|
'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
|
|
1874
1925
|
"",
|
|
@@ -1879,7 +1930,9 @@ defineTool({
|
|
|
1879
1930
|
key: z10.string().min(1).max(128).describe("Env-var key."),
|
|
1880
1931
|
value: z10.string().describe("New value."),
|
|
1881
1932
|
is_secret: z10.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
|
|
1882
|
-
target: z10.enum(["build", "runtime", "both"]).optional().describe(
|
|
1933
|
+
target: z10.enum(["build", "runtime", "both"]).optional().describe(
|
|
1934
|
+
'Injection target: build, runtime, or both. Default both. A secret var on "both" is runtime-only (build args land in image history) \u2014 pass is_secret:false for a public build-time value, or use "build" to force it.'
|
|
1935
|
+
)
|
|
1883
1936
|
},
|
|
1884
1937
|
handler: async (args, ctx) => {
|
|
1885
1938
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1962,6 +2015,7 @@ defineTool({
|
|
|
1962
2015
|
"Inputs:",
|
|
1963
2016
|
" - service_id: publicId of the service.",
|
|
1964
2017
|
' - env_vars: array of { key, value, is_secret?, target? }. is_secret defaults to FALSE per row (a row is stored in the clear unless you set is_secret:true); target defaults to "both". Note this differs from set_env_var, which defaults a newly-created var to secret.',
|
|
2018
|
+
' A var reaches the image BUILD when target is "build", or when target is "both" and is_secret is false. A secret on "both" is runtime-only, because build args are visible in image history.',
|
|
1965
2019
|
"",
|
|
1966
2020
|
"Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
|
|
1967
2021
|
"",
|
|
@@ -2153,6 +2207,15 @@ defineTool({
|
|
|
2153
2207
|
|
|
2154
2208
|
// src/tools/analytics.ts
|
|
2155
2209
|
import { z as z12 } from "zod";
|
|
2210
|
+
|
|
2211
|
+
// src/lib/format.ts
|
|
2212
|
+
var MCP_LOCALE = "en-IE";
|
|
2213
|
+
var COUNT_FORMAT = new Intl.NumberFormat(MCP_LOCALE);
|
|
2214
|
+
function formatCount(value) {
|
|
2215
|
+
return COUNT_FORMAT.format(value);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// src/tools/analytics.ts
|
|
2156
2219
|
async function resolveSiteIds(domains, teamId, api) {
|
|
2157
2220
|
if (!domains || domains.length === 0) return void 0;
|
|
2158
2221
|
const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
|
|
@@ -2171,6 +2234,10 @@ async function resolveSiteIds(domains, teamId, api) {
|
|
|
2171
2234
|
}
|
|
2172
2235
|
return ids.join(",");
|
|
2173
2236
|
}
|
|
2237
|
+
var ALLOWED_ORIGINS_INPUT = z12.array(z12.string().trim().min(1).max(253)).max(20).describe(
|
|
2238
|
+
'Extra origins allowed to post events, beyond the site domain and its subdomains (both are always allowed). Bare hostnames, e.g. ["staging.example.com", "localhost:5173"]. Replaces the whole list; pass [] to clear it.'
|
|
2239
|
+
);
|
|
2240
|
+
var RETENTION_DAYS_INPUT = z12.number().int().positive().describe("How many days of raw events to keep. Rollups outlive this.");
|
|
2174
2241
|
defineTool({
|
|
2175
2242
|
name: "list_analytics_sites",
|
|
2176
2243
|
category: "analytics",
|
|
@@ -2206,7 +2273,9 @@ defineTool({
|
|
|
2206
2273
|
"",
|
|
2207
2274
|
"The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
|
|
2208
2275
|
"",
|
|
2209
|
-
"Returns health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
|
|
2276
|
+
"Returns: health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), refusedOrigins (which origins were turned away for bad_origin and how often, over the same window), the site key the snippet must carry, allowed origins, and quota usage this hour.",
|
|
2277
|
+
"",
|
|
2278
|
+
"Use refusedOrigins, not lastRefusalOrigin, to decide what to allow: lastRefusalOrigin is the newest refusal of ANY reason, and bots outnumber everything, so one crawler hit overwrites the origin behind a real outage. Feed what you find to update_analytics_site \u2014 and if the origin is NOT the user's, the site key is public by design and somebody has pasted it into their own page, so the answer is to rotate the key rather than allow them.",
|
|
2210
2279
|
"",
|
|
2211
2280
|
"health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
|
|
2212
2281
|
"",
|
|
@@ -2238,6 +2307,8 @@ defineTool({
|
|
|
2238
2307
|
"",
|
|
2239
2308
|
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
|
|
2240
2309
|
"",
|
|
2310
|
+
"Returns: the range asked for, and one row per site \u2014 domain, `current` and `previous` blocks of visitors / pageviews / bounceRate / avgVisitSeconds, the live visitor count, and `visitorsAreSummedDailies`, which says whether that visitor number is a distinct count or a sum of daily uniques. Quote that flag with any 90d or 12mo figure.",
|
|
2311
|
+
"",
|
|
2241
2312
|
"Example: get_analytics_summary({ range: '30d' }) \u2192 { range: '30d', sites: [{ domain: 'hoststack.dev', current: { visitors: 4210, pageviews: 11890, bounceRate: 47.2 }, previous: {\u2026}, live: 3, visitorsAreSummedDailies: false }] }."
|
|
2242
2313
|
].join("\n"),
|
|
2243
2314
|
input: {
|
|
@@ -2269,6 +2340,8 @@ defineTool({
|
|
|
2269
2340
|
"",
|
|
2270
2341
|
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site), filters (optional map \u2014 country, browser, os, device, language, path, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent).",
|
|
2271
2342
|
"",
|
|
2343
|
+
"Returns: `source` ('raw' | 'rollup') and `filtersSupported`, which together say how much of the request was actually honoured; a `summary` block of current-vs-previous totals; a timeseries; and the breakdowns \u2014 topPaths, topReferrers, topEvents, browsers, operatingSystems, languages, screenSizes, campaigns, devices and countries. Read `source` and `filtersSupported` before quoting any of it.",
|
|
2344
|
+
"",
|
|
2272
2345
|
"Example: get_analytics_overview({ domains: ['hoststack.dev'], range: '7d', filters: { country: 'DK' } }) \u2192 { source: 'raw', filtersSupported: true, summary: { current: { visitors: 310, \u2026 } }, topPaths: [{ path: '/pricing', pageviews: 214 }], topReferrers: [{ referrer: 'Direct', visits: 190 }], \u2026 }."
|
|
2273
2346
|
].join("\n"),
|
|
2274
2347
|
input: {
|
|
@@ -2296,7 +2369,7 @@ defineTool({
|
|
|
2296
2369
|
"the filters you passed were NOT applied \u2014 they only work on shorter ranges"
|
|
2297
2370
|
);
|
|
2298
2371
|
}
|
|
2299
|
-
const summary = `${current.pageviews
|
|
2372
|
+
const summary = `${formatCount(current.pageviews)} pageviews from ${formatCount(current.visitors)} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
|
|
2300
2373
|
return respond({ summary, data: response });
|
|
2301
2374
|
}
|
|
2302
2375
|
});
|
|
@@ -2310,19 +2383,27 @@ defineTool({
|
|
|
2310
2383
|
"",
|
|
2311
2384
|
"If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
|
|
2312
2385
|
"",
|
|
2313
|
-
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
|
|
2386
|
+
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain), allowed_origins (optional extra origins beyond the domain and its subdomains), retention_days (optional raw-event retention).',
|
|
2387
|
+
"",
|
|
2388
|
+
"To change any of these later \u2014 or to attach the site to a service \u2014 use update_analytics_site. Creating is not the only chance to set them.",
|
|
2389
|
+
"",
|
|
2390
|
+
"Returns: the created site (id, domain, name, ingestKey, retentionDays) and the ready-made `snippet` to paste into the page \u2014 the key is in that snippet, so hand it over rather than describing it.",
|
|
2314
2391
|
"",
|
|
2315
2392
|
`Example: create_analytics_site({ domain: 'micci.dk' }) \u2192 { site: { id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026' }, snippet: '<script defer src="https://hoststack.dev/t.js" data-site-key="site_\u2026"></script>' }.`
|
|
2316
2393
|
].join("\n"),
|
|
2317
2394
|
input: {
|
|
2318
2395
|
domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
|
|
2319
|
-
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
|
|
2396
|
+
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain."),
|
|
2397
|
+
allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
|
|
2398
|
+
retention_days: RETENTION_DAYS_INPUT.optional()
|
|
2320
2399
|
},
|
|
2321
2400
|
handler: async (args, ctx) => {
|
|
2322
2401
|
const teamId = await ctx.resolveTeamId();
|
|
2323
2402
|
const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
|
|
2324
2403
|
domain: args.domain,
|
|
2325
|
-
...args.name ? { name: args.name } : {}
|
|
2404
|
+
...args.name ? { name: args.name } : {},
|
|
2405
|
+
...args.allowed_origins ? { allowedOrigins: args.allowed_origins } : {},
|
|
2406
|
+
...args.retention_days ? { retentionDays: args.retention_days } : {}
|
|
2326
2407
|
});
|
|
2327
2408
|
const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
|
|
2328
2409
|
return respond({
|
|
@@ -2331,6 +2412,181 @@ defineTool({
|
|
|
2331
2412
|
});
|
|
2332
2413
|
}
|
|
2333
2414
|
});
|
|
2415
|
+
defineTool({
|
|
2416
|
+
name: "update_analytics_site",
|
|
2417
|
+
category: "analytics",
|
|
2418
|
+
description: [
|
|
2419
|
+
"Change an existing analytics site: its allowed origins, display name, retention, or which service it belongs to.",
|
|
2420
|
+
"",
|
|
2421
|
+
"When to use: events are being refused with `bad_origin` because they come from an origin the site does not list (check_analytics_site names it), a site needs a clearer name, retention should change, or the site should appear on a service's Analytics tab.",
|
|
2422
|
+
"",
|
|
2423
|
+
"Inputs: domain (required \u2014 the site to change, by its bare hostname), then any of allowed_origins, name, retention_days, service_id. Everything is optional except domain; only the fields you pass are touched.",
|
|
2424
|
+
"",
|
|
2425
|
+
"allowed_origins REPLACES the list rather than appending, so read the current one from list_analytics_sites first and send it back with your addition. The site domain and its subdomains are always allowed and never need listing.",
|
|
2426
|
+
"",
|
|
2427
|
+
"Returns: { site } \u2014 the site as it now stands.",
|
|
2428
|
+
"",
|
|
2429
|
+
"Example: update_analytics_site({ domain: 'example.com', allowed_origins: ['staging.example.com'] }) \u2192 { site: { domain: 'example.com', allowedOrigins: ['staging.example.com'], \u2026 } }."
|
|
2430
|
+
].join("\n"),
|
|
2431
|
+
input: {
|
|
2432
|
+
domain: z12.string().min(1).max(253).describe("Bare hostname of a site this team tracks."),
|
|
2433
|
+
allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
|
|
2434
|
+
name: z12.string().trim().min(1).max(100).optional().describe("New display name."),
|
|
2435
|
+
retention_days: RETENTION_DAYS_INPUT.optional(),
|
|
2436
|
+
service_id: z12.number().int().positive().nullable().optional().describe("Numeric service id to attach this site to, or null to detach it.")
|
|
2437
|
+
},
|
|
2438
|
+
handler: async (args, ctx) => {
|
|
2439
|
+
const teamId = await ctx.resolveTeamId();
|
|
2440
|
+
const siteId = await resolveSiteIds([args.domain], teamId, ctx.api);
|
|
2441
|
+
const patch = {};
|
|
2442
|
+
if (args.allowed_origins !== void 0) patch["allowedOrigins"] = args.allowed_origins;
|
|
2443
|
+
if (args.name !== void 0) patch["name"] = args.name;
|
|
2444
|
+
if (args.retention_days !== void 0) patch["retentionDays"] = args.retention_days;
|
|
2445
|
+
if (args.service_id !== void 0) patch["serviceId"] = args.service_id;
|
|
2446
|
+
if (Object.keys(patch).length === 0) {
|
|
2447
|
+
throw new Error(
|
|
2448
|
+
"Nothing to update. Pass at least one of allowed_origins, name, retention_days or service_id."
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
const site = await ctx.api.patch(
|
|
2452
|
+
`/api/analytics/${teamId}/sites/${siteId}`,
|
|
2453
|
+
patch
|
|
2454
|
+
);
|
|
2455
|
+
const changed = Object.keys(patch).join(", ");
|
|
2456
|
+
return respond({
|
|
2457
|
+
summary: `Updated ${site.domain} (${changed}).`,
|
|
2458
|
+
data: { site: shape(site) }
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
defineTool({
|
|
2463
|
+
name: "verify_site_domain",
|
|
2464
|
+
category: "analytics",
|
|
2465
|
+
description: [
|
|
2466
|
+
"Prove the team owns a site's domain, by publishing a TXT record.",
|
|
2467
|
+
"",
|
|
2468
|
+
"When to use: before turning on an uptime check for a site that HostStack does not host. Analytics itself needs no proof \u2014 the ingest key only labels events the site posts about itself \u2014 so do NOT send a user through this just to get a dashboard. It exists for the capabilities where the PLATFORM acts on the hostname.",
|
|
2469
|
+
"",
|
|
2470
|
+
"Call it once with no `check` to get the record to publish, then again with check=true once the record is live. The token is stable across calls, so it is safe to re-read the instructions while the user is mid-paste.",
|
|
2471
|
+
"",
|
|
2472
|
+
"A site whose domain is already verified on a service in this team is proven ALREADY and does not need this \u2014 `list_analytics_sites` reports that as domainProven. This is for the site with no service behind it, which can never have such a link.",
|
|
2473
|
+
"",
|
|
2474
|
+
`The record is read from the domain's own authoritative nameservers rather than through a cache, so "I just added it" works immediately instead of being masked by a negative-cache TTL.`,
|
|
2475
|
+
"",
|
|
2476
|
+
'Inputs: siteId (required), check (optional, default false \u2014 true means "look at DNS now and tell me the verdict").',
|
|
2477
|
+
"",
|
|
2478
|
+
"Returns: the record to publish plus `verified`. A `verified: false` is a successful check with a negative answer, not an error \u2014 most often the record simply has not propagated yet.",
|
|
2479
|
+
"",
|
|
2480
|
+
"Example: verify_site_domain({ siteId: 3 }) \u2192 { recordName: '_hoststack-verify.poststack.dev', recordType: 'TXT', recordValue: 'hoststack-verify=9f3c\u2026', verified: false }."
|
|
2481
|
+
].join("\n"),
|
|
2482
|
+
input: {
|
|
2483
|
+
siteId: z12.number().int().positive(),
|
|
2484
|
+
check: z12.boolean().optional().describe(
|
|
2485
|
+
"True to read DNS now and return the verdict. False just returns the record."
|
|
2486
|
+
)
|
|
2487
|
+
},
|
|
2488
|
+
handler: async (args, ctx) => {
|
|
2489
|
+
const teamId = await ctx.resolveTeamId();
|
|
2490
|
+
const instructions = await ctx.api.get(`/api/analytics/${teamId}/sites/${args.siteId}/verification`);
|
|
2491
|
+
if (!args.check) {
|
|
2492
|
+
return respond({
|
|
2493
|
+
summary: instructions.verified ? "Already verified." : `Publish a ${instructions.recordType} record at ${instructions.recordName} with the value shown, then call again with check=true.`,
|
|
2494
|
+
data: shape(instructions)
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
const verdict = await ctx.api.post(
|
|
2498
|
+
`/api/analytics/${teamId}/sites/${args.siteId}/verify`,
|
|
2499
|
+
{}
|
|
2500
|
+
);
|
|
2501
|
+
return respond({
|
|
2502
|
+
summary: verdict.verified ? "Verified. This site can now be given an uptime check." : `Not verified yet \u2014 ${verdict.detail ?? "the record was not found"}.`,
|
|
2503
|
+
data: { record: shape(instructions), result: shape(verdict) }
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
defineTool({
|
|
2508
|
+
name: "get_site_uptime_check",
|
|
2509
|
+
category: "analytics",
|
|
2510
|
+
description: [
|
|
2511
|
+
"Read a site's uptime check without touching it.",
|
|
2512
|
+
"",
|
|
2513
|
+
'When to use: answer "has it probed yet?", "is it up?", "how many failures in a row?" \u2014 the site counterpart of get_uptime_check.',
|
|
2514
|
+
"",
|
|
2515
|
+
'READ THIS BEFORE REACHING FOR set_site_uptime_check TO INSPECT ONE. The setter RESETS the accumulated state it returns (status back to "unknown", consecutiveFailures to 0, lastCheckedAt to null), because a check whose shape just changed has not observed the new shape failing. So using it to look at a check is what stops the check ever showing a probe: every read restarts the measurement, and a working check reads as one that never runs. This tool exists because that trap cost a real investigation an afternoon.',
|
|
2516
|
+
"",
|
|
2517
|
+
"Returns: { check } \u2014 enabled, path, method, expectedStatus, timeoutMs, intervalSeconds, failureThreshold, plus live state: status ('up' | 'down' | 'unknown'), consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt. `null` when the site has no check.",
|
|
2518
|
+
"",
|
|
2519
|
+
"Example: get_site_uptime_check({ siteId: 12 }) \u2192 { check: { status: 'up', lastStatusCode: 200, lastCheckedAt: '2026-09-09T05:27:34Z' } }."
|
|
2520
|
+
].join("\n"),
|
|
2521
|
+
input: {
|
|
2522
|
+
siteId: z12.number().int().positive()
|
|
2523
|
+
},
|
|
2524
|
+
handler: async (args, ctx) => {
|
|
2525
|
+
const teamId = await ctx.resolveTeamId();
|
|
2526
|
+
const response = await ctx.api.get(
|
|
2527
|
+
`/api/analytics/${teamId}/sites/${args.siteId}/uptime-check`
|
|
2528
|
+
);
|
|
2529
|
+
if (!response.check) {
|
|
2530
|
+
return respond({
|
|
2531
|
+
summary: "No uptime check on this site \u2014 nothing is watching it.",
|
|
2532
|
+
data: { check: null }
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
const check = shape(response.check);
|
|
2536
|
+
const lastCheckedAt = check.lastCheckedAt;
|
|
2537
|
+
return respond({
|
|
2538
|
+
summary: `Uptime check is ${String(check.status ?? "unknown")}${lastCheckedAt ? `, last probed ${String(lastCheckedAt)}` : ", not probed yet"}.`,
|
|
2539
|
+
data: { check }
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
defineTool({
|
|
2544
|
+
name: "set_site_uptime_check",
|
|
2545
|
+
category: "analytics",
|
|
2546
|
+
description: [
|
|
2547
|
+
"Watch a site HostStack does not host \u2014 request its URL on a schedule and alert when it stops answering.",
|
|
2548
|
+
"",
|
|
2549
|
+
"When to use: the user has a site running somewhere else (their own box, another provider) and wants to know when it goes down. This is the one observability capability an off-platform site cannot provide for itself: analytics is a script tag and error reporting is an HTTP POST, but an outside-in probe has to come from outside.",
|
|
2550
|
+
"",
|
|
2551
|
+
"Works on a site HostStack DOES host too, and that is not a worse option: this check names ONE hostname \u2014 the site's own proven domain \u2014 where set_uptime_check follows whichever of the service's domains is primary at probe time. A service answering on a canonical host plus a 301 alias has no single correct expectedStatus, so a site check per hostname is how each one gets asserted, and nothing in the prober treats a service-backed site differently. Use set_uptime_check when the check should follow the service's domain; use this one when a named host is the point.",
|
|
2552
|
+
"",
|
|
2553
|
+
'To READ a check, call get_site_uptime_check. Changing the shape of a check RESETS its accumulated state (status to "unknown", consecutiveFailures to 0, lastCheckedAt to null) \u2014 the new shape has not been measured yet, so carrying failures forward would alert about a condition nobody observed. A write that resolves to the shape ALREADY STORED changes nothing and leaves that state alone. The inputs are defaults rather than a partial edit, though: omitting intervalSeconds on a check currently set to 30 resolves to 60, which IS a change.',
|
|
2554
|
+
"",
|
|
2555
|
+
"REQUIRES a proven domain. Call verify_site_domain first if `domainProven` is false; an unverified target is refused with 400. This is not paperwork: an uptime check makes the control plane fetch the hostname every interval, forever, from our IP, so it must be a name the team has shown it owns.",
|
|
2556
|
+
"",
|
|
2557
|
+
"Always https, and the host is the site's domain \u2014 `path` is a path, not a URL. Redirects are NOT followed: a 301 is an answer, and following one can walk the probe onto a parked page and report a dead site as healthy. Set expectedStatus to the redirect if one is expected.",
|
|
2558
|
+
"",
|
|
2559
|
+
'Inputs (all optional except siteId): 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).',
|
|
2560
|
+
"",
|
|
2561
|
+
"Returns: the stored check \u2014 enabled, the resolved target URL, method, expectedStatus, timeoutMs, intervalSeconds, failureThreshold \u2014 plus its live state: status ('up' | 'down' | 'unknown'), lastCheckedAt, lastStatusCode and consecutiveFailures. A freshly created check is 'unknown' until the first probe runs.",
|
|
2562
|
+
"",
|
|
2563
|
+
"Example: set_site_uptime_check({ siteId: 3, path: '/health', intervalSeconds: 60 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
2564
|
+
].join("\n"),
|
|
2565
|
+
input: {
|
|
2566
|
+
siteId: z12.number().int().positive(),
|
|
2567
|
+
enabled: z12.boolean().optional(),
|
|
2568
|
+
path: z12.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
2569
|
+
method: z12.enum(["GET", "HEAD"]).optional(),
|
|
2570
|
+
expectedStatus: z12.number().int().min(100).max(599).optional(),
|
|
2571
|
+
timeoutMs: z12.number().int().min(1e3).max(6e4).optional(),
|
|
2572
|
+
intervalSeconds: z12.number().int().min(30).max(3600).optional(),
|
|
2573
|
+
failureThreshold: z12.number().int().min(1).max(10).optional()
|
|
2574
|
+
},
|
|
2575
|
+
handler: async (args, ctx) => {
|
|
2576
|
+
const teamId = await ctx.resolveTeamId();
|
|
2577
|
+
const { siteId, ...body } = args;
|
|
2578
|
+
const response = await ctx.api.put(
|
|
2579
|
+
`/api/analytics/${teamId}/sites/${siteId}/uptime-check`,
|
|
2580
|
+
body
|
|
2581
|
+
);
|
|
2582
|
+
const check = shape(response.check);
|
|
2583
|
+
const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
|
|
2584
|
+
return respond({
|
|
2585
|
+
summary: probed ? `Uptime check saved \u2014 the shape was already stored, so nothing was reset: still ${String(check["status"])}, last probed ${String(check["lastCheckedAt"])}.` : 'Uptime check saved, and not probed yet \u2014 "unknown" with no lastCheckedAt is the state of a check that has not run once, not a broken one. It reports within a minute or two: read it with get_site_uptime_check, never by saving it again.',
|
|
2586
|
+
data: { check }
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2334
2590
|
|
|
2335
2591
|
// src/tools/errors.ts
|
|
2336
2592
|
import { z as z13 } from "zod";
|
|
@@ -2624,6 +2880,61 @@ defineTool({
|
|
|
2624
2880
|
}
|
|
2625
2881
|
});
|
|
2626
2882
|
|
|
2883
|
+
// src/tools/issue-reports.ts
|
|
2884
|
+
import { z as z14 } from "zod";
|
|
2885
|
+
defineTool({
|
|
2886
|
+
name: "report_issue",
|
|
2887
|
+
category: "support",
|
|
2888
|
+
description: [
|
|
2889
|
+
"File a bug or platform fault with the HostStack team. Use this when something on the PLATFORM is broken in a way you cannot fix from where you stand \u2014 a deploy that fails identically no matter what you change, a dev box that will not start, an API that returns the wrong thing, an error message that does not match what actually happened.",
|
|
2890
|
+
"",
|
|
2891
|
+
'When to use: you have already looked. This is not a substitute for reading logs (get_deploy_logs, get_service_logs) or for diagnose_deploy \u2014 it is what you do once you have a specific fault and no way to act on it. A report that says "the deploy failed" is worth less than no report; one that says "every deploy since 09:03 fails at container create with 404 No such image, but the image pulls fine by hand" is what gets fixed.',
|
|
2892
|
+
"",
|
|
2893
|
+
"When NOT to use: an application bug in the user's own code, a failing test, a misconfigured env var, or anything the user asked you to change. Those are your job, not a platform fault. Do not file the same issue twice \u2014 if you already reported it this session, say so instead.",
|
|
2894
|
+
"",
|
|
2895
|
+
"Diagnostic context is attached SERVER-SIDE, so you do not need to paste it: passing `serviceId` attaches the service and its most recent deploy, and the last 40 lines of that deploy log go with it. Pass `deployId` to pin a specific deploy instead of the newest. Describe what you observed and what you expected \u2014 the evidence is collected for you.",
|
|
2896
|
+
"",
|
|
2897
|
+
"Inputs: title (required, one line), description (required \u2014 what happened, what you expected, what you already ruled out), severity (low|normal|high|urgent, default normal), serviceId (optional), deployId (optional), databaseId (optional).",
|
|
2898
|
+
"",
|
|
2899
|
+
"Rate-limited to 5 reports per minute per team. Filing one notifies the HostStack team and opens a ticket the user can see and reply to.",
|
|
2900
|
+
"",
|
|
2901
|
+
"Returns: { ticket: { id, publicId } } \u2014 quote the publicId to the user so they can follow it up.",
|
|
2902
|
+
"",
|
|
2903
|
+
"Example: report_issue({ title: 'Dev box deploys fail with 404 No such image', description: 'Every deploy of svc 172 since 2026-08-22 09:03 fails at container create with `404 No such image: \u2026/dev-env:latest`. The deploy log reports \"Image ready in 1s\" for a 5.5 GB image, so the pull is not happening. The image pulls fine by hand from the same registry.', severity: 'high', serviceId: 172 }) \u2192 { ticket: { publicId: 'tkt_\u2026' } }"
|
|
2904
|
+
].join("\n"),
|
|
2905
|
+
input: {
|
|
2906
|
+
title: z14.string().min(1).max(300).describe("One-line summary of the fault."),
|
|
2907
|
+
description: z14.string().min(1).max(1e4).describe("What happened, what you expected instead, and what you already ruled out."),
|
|
2908
|
+
severity: z14.enum(["low", "normal", "high", "urgent"]).optional().describe(
|
|
2909
|
+
'Default normal. Use high/urgent only when something is DOWN or losing data \u2014 not for "this is annoying".'
|
|
2910
|
+
),
|
|
2911
|
+
serviceId: z14.number().int().positive().optional().describe(
|
|
2912
|
+
"The affected service. Attaches the service, its latest deploy, and that deploy log tail automatically."
|
|
2913
|
+
),
|
|
2914
|
+
deployId: z14.number().int().positive().optional().describe("Pin a specific deploy instead of the service\u2019s most recent one."),
|
|
2915
|
+
databaseId: z14.number().int().positive().optional().describe("The affected database.")
|
|
2916
|
+
},
|
|
2917
|
+
handler: async (args, ctx) => {
|
|
2918
|
+
const teamId = await ctx.resolveTeamId();
|
|
2919
|
+
const body = {
|
|
2920
|
+
title: args.title,
|
|
2921
|
+
description: args.description,
|
|
2922
|
+
severity: args.severity ?? "normal"
|
|
2923
|
+
};
|
|
2924
|
+
if (args.serviceId !== void 0) body["serviceId"] = args.serviceId;
|
|
2925
|
+
if (args.deployId !== void 0) body["deployId"] = args.deployId;
|
|
2926
|
+
if (args.databaseId !== void 0) body["databaseId"] = args.databaseId;
|
|
2927
|
+
const response = await ctx.api.post(
|
|
2928
|
+
`/api/issue-reports/${teamId}`,
|
|
2929
|
+
body
|
|
2930
|
+
);
|
|
2931
|
+
return respond({
|
|
2932
|
+
summary: `Issue reported as ${response.ticket.publicId}. Tell the user the reference so they can follow it up \u2014 and do not file this one again.`,
|
|
2933
|
+
data: shape(response.ticket)
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2627
2938
|
// src/tools/meta.ts
|
|
2628
2939
|
var DEV_ENV_TOOL_NAMES = [
|
|
2629
2940
|
"create_dev_environment",
|
|
@@ -2689,7 +3000,7 @@ defineTool({
|
|
|
2689
3000
|
});
|
|
2690
3001
|
|
|
2691
3002
|
// src/tools/notifications.ts
|
|
2692
|
-
import { z as
|
|
3003
|
+
import { z as z15 } from "zod";
|
|
2693
3004
|
var NOTIFICATION_EVENTS = [
|
|
2694
3005
|
"deploy.started",
|
|
2695
3006
|
"deploy.succeeded",
|
|
@@ -2700,11 +3011,17 @@ var NOTIFICATION_EVENTS = [
|
|
|
2700
3011
|
"service.suspended",
|
|
2701
3012
|
"service.resumed",
|
|
2702
3013
|
"service.restart_failed",
|
|
2703
|
-
"service.
|
|
3014
|
+
"service.no_running_container",
|
|
3015
|
+
"service.health_check_failed",
|
|
2704
3016
|
"service.acme_cert_failed",
|
|
2705
3017
|
"service.resource_alert",
|
|
3018
|
+
"service.pressure_sustained",
|
|
3019
|
+
"service.pressure_recovered",
|
|
2706
3020
|
"service.uptime_down",
|
|
2707
3021
|
"service.uptime_recovered",
|
|
3022
|
+
"watchdog.reported_down",
|
|
3023
|
+
"watchdog.reported_recovered",
|
|
3024
|
+
"watchdog.silent",
|
|
2708
3025
|
"error.issue_new",
|
|
2709
3026
|
"error.issue_regressed",
|
|
2710
3027
|
"git.auth_failed",
|
|
@@ -2716,7 +3033,13 @@ var NOTIFICATION_EVENTS = [
|
|
|
2716
3033
|
"devenv.task.needs_input",
|
|
2717
3034
|
"devenv.task.finished",
|
|
2718
3035
|
"database.backup_failed",
|
|
3036
|
+
"database.backup_overdue",
|
|
3037
|
+
"database.failed",
|
|
2719
3038
|
"database.restore_failed",
|
|
3039
|
+
"volume.backup_failed",
|
|
3040
|
+
"volume.backup_overdue",
|
|
3041
|
+
"domain.registrant_verification_lapsed",
|
|
3042
|
+
"service.auto_restarted",
|
|
2720
3043
|
"machine.offline",
|
|
2721
3044
|
"machine.online",
|
|
2722
3045
|
"billing.invoice",
|
|
@@ -2771,10 +3094,10 @@ defineTool({
|
|
|
2771
3094
|
"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'] })"
|
|
2772
3095
|
].join("\n"),
|
|
2773
3096
|
input: {
|
|
2774
|
-
type:
|
|
2775
|
-
name:
|
|
2776
|
-
webhook_url:
|
|
2777
|
-
events:
|
|
3097
|
+
type: z15.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
3098
|
+
name: z15.string().min(1).max(128).describe("Human-readable label."),
|
|
3099
|
+
webhook_url: z15.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
3100
|
+
events: z15.array(z15.enum(NOTIFICATION_EVENTS)).describe(
|
|
2778
3101
|
"List of events the channel subscribes to. Empty list = subscribe to nothing."
|
|
2779
3102
|
)
|
|
2780
3103
|
},
|
|
@@ -2812,10 +3135,10 @@ defineTool({
|
|
|
2812
3135
|
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
2813
3136
|
].join("\n"),
|
|
2814
3137
|
input: {
|
|
2815
|
-
channel_id:
|
|
2816
|
-
name:
|
|
2817
|
-
active:
|
|
2818
|
-
events:
|
|
3138
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
3139
|
+
name: z15.string().min(1).max(128).optional().describe("New label."),
|
|
3140
|
+
active: z15.boolean().optional().describe("false silences without deleting."),
|
|
3141
|
+
events: z15.array(z15.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
2819
3142
|
},
|
|
2820
3143
|
handler: async (args, ctx) => {
|
|
2821
3144
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2854,7 +3177,7 @@ defineTool({
|
|
|
2854
3177
|
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
2855
3178
|
].join("\n"),
|
|
2856
3179
|
input: {
|
|
2857
|
-
channel_id:
|
|
3180
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id.")
|
|
2858
3181
|
},
|
|
2859
3182
|
handler: async (args, ctx) => {
|
|
2860
3183
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2881,7 +3204,7 @@ defineTool({
|
|
|
2881
3204
|
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
2882
3205
|
].join("\n"),
|
|
2883
3206
|
input: {
|
|
2884
|
-
channel_id:
|
|
3207
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id.")
|
|
2885
3208
|
},
|
|
2886
3209
|
handler: async (args, ctx) => {
|
|
2887
3210
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2896,7 +3219,7 @@ defineTool({
|
|
|
2896
3219
|
});
|
|
2897
3220
|
|
|
2898
3221
|
// src/tools/projects.ts
|
|
2899
|
-
import { z as
|
|
3222
|
+
import { z as z16 } from "zod";
|
|
2900
3223
|
var AVAILABLE_REGION_IDS = ["eu-central-1"];
|
|
2901
3224
|
defineTool({
|
|
2902
3225
|
name: "list_projects",
|
|
@@ -2937,9 +3260,9 @@ defineTool({
|
|
|
2937
3260
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
2938
3261
|
].join("\n"),
|
|
2939
3262
|
input: {
|
|
2940
|
-
name:
|
|
2941
|
-
description:
|
|
2942
|
-
region:
|
|
3263
|
+
name: z16.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
3264
|
+
description: z16.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
3265
|
+
region: z16.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
|
|
2943
3266
|
},
|
|
2944
3267
|
handler: async (args, ctx) => {
|
|
2945
3268
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2972,9 +3295,9 @@ defineTool({
|
|
|
2972
3295
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
2973
3296
|
].join("\n"),
|
|
2974
3297
|
input: {
|
|
2975
|
-
project_id:
|
|
2976
|
-
name:
|
|
2977
|
-
description:
|
|
3298
|
+
project_id: z16.string().describe("Project publicId."),
|
|
3299
|
+
name: z16.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
3300
|
+
description: z16.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
2978
3301
|
},
|
|
2979
3302
|
handler: async (args, ctx) => {
|
|
2980
3303
|
if (args.name === void 0 && args.description === void 0) {
|
|
@@ -3008,7 +3331,7 @@ defineTool({
|
|
|
3008
3331
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
3009
3332
|
].join("\n"),
|
|
3010
3333
|
input: {
|
|
3011
|
-
project_id:
|
|
3334
|
+
project_id: z16.string().describe("Project publicId (e.g. prj_abc123).")
|
|
3012
3335
|
},
|
|
3013
3336
|
handler: async (args, ctx) => {
|
|
3014
3337
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3019,8 +3342,156 @@ defineTool({
|
|
|
3019
3342
|
}
|
|
3020
3343
|
});
|
|
3021
3344
|
|
|
3345
|
+
// src/tools/dev-tasks.ts
|
|
3346
|
+
import { z as z17 } from "zod";
|
|
3347
|
+
var NOT_A_RUN = "Filing a task does NOT start an agent. It writes a prompt into the box's backlog for someone to run; a suspended box keeps it until it wakes.";
|
|
3348
|
+
defineTool({
|
|
3349
|
+
name: "list_dev_tasks",
|
|
3350
|
+
category: "dev-tasks",
|
|
3351
|
+
description: [
|
|
3352
|
+
"List a project's agent task backlog - the same list the dashboard's Development \u2192 Tasks surface shows.",
|
|
3353
|
+
"",
|
|
3354
|
+
"When to use: to see what is already queued for a box before filing something (the backlog is how you avoid two agents being pointed at one job), or to find the task id for update_dev_task.",
|
|
3355
|
+
"",
|
|
3356
|
+
"Inputs:",
|
|
3357
|
+
' - project_id: numeric project id (from list_projects) or its "prj_\u2026" publicId.',
|
|
3358
|
+
"",
|
|
3359
|
+
"Returns: { summary, data: { items: DevTask[] } } - each task with publicId, title, status, serviceId (the box, or null for a loose idea), createdAt.",
|
|
3360
|
+
"",
|
|
3361
|
+
"`status` spans two worlds: `idea`/`done` are what a person sets, while `queued`/`running`/`needs_input`/`failed`/`cancelled` describe an agent run and belong to the runner.",
|
|
3362
|
+
"",
|
|
3363
|
+
'Example: list_dev_tasks({ project_id: 26 }) \u2192 { items: [{ publicId: "task_\u2026", status: "idea", title: "Fix the footprint join" }] }'
|
|
3364
|
+
].join("\n"),
|
|
3365
|
+
input: {
|
|
3366
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.')
|
|
3367
|
+
},
|
|
3368
|
+
handler: async (args, ctx) => {
|
|
3369
|
+
const teamId = await ctx.resolveTeamId();
|
|
3370
|
+
const response = await ctx.hoststack.devTasks.list(teamId, args.project_id);
|
|
3371
|
+
const data = shapeList(response, "tasks", shape);
|
|
3372
|
+
const open = data.items.filter(
|
|
3373
|
+
(t) => t && typeof t === "object" && "status" in t && !["done", "cancelled"].includes(String(t.status))
|
|
3374
|
+
).length;
|
|
3375
|
+
const summary = data.items.length === 0 ? "No tasks in this project." : `${data.items.length} task${data.items.length === 1 ? "" : "s"}, ${open} still open.${response.automodeEnabled ? "" : " Automode is off here, so a queued task waits for someone to start it."}`;
|
|
3376
|
+
return respond({ summary, data });
|
|
3377
|
+
}
|
|
3378
|
+
});
|
|
3379
|
+
defineTool({
|
|
3380
|
+
name: "get_dev_task",
|
|
3381
|
+
category: "dev-tasks",
|
|
3382
|
+
description: [
|
|
3383
|
+
"Get one task, including the full prompt body.",
|
|
3384
|
+
"",
|
|
3385
|
+
"When to use: to read what a task actually asks for before acting on it or marking it done - `list_dev_tasks` returns titles, not prompts.",
|
|
3386
|
+
"",
|
|
3387
|
+
"Inputs:",
|
|
3388
|
+
' - task_id: "task_\u2026" publicId or numeric id.',
|
|
3389
|
+
"",
|
|
3390
|
+
"Returns: { summary, data: DevTask }.",
|
|
3391
|
+
"",
|
|
3392
|
+
'Example: get_dev_task({ task_id: "task_hy1i2jdp\u2026" }) \u2192 { data: { title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026", status: "idea" } }'
|
|
3393
|
+
].join("\n"),
|
|
3394
|
+
input: {
|
|
3395
|
+
task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.')
|
|
3396
|
+
},
|
|
3397
|
+
handler: async (args, ctx) => {
|
|
3398
|
+
const teamId = await ctx.resolveTeamId();
|
|
3399
|
+
const response = await ctx.hoststack.devTasks.get(teamId, args.task_id);
|
|
3400
|
+
const data = shape(response.task);
|
|
3401
|
+
return respond({ summary: `Task ${response.task.publicId}: ${response.task.title}`, data });
|
|
3402
|
+
}
|
|
3403
|
+
});
|
|
3404
|
+
defineTool({
|
|
3405
|
+
name: "create_dev_task",
|
|
3406
|
+
category: "dev-tasks",
|
|
3407
|
+
description: [
|
|
3408
|
+
"File a task in a project's backlog, optionally pinned to a specific dev box.",
|
|
3409
|
+
"",
|
|
3410
|
+
`When to use: to hand work to a dev box other than the one you are in - a fix that belongs in an upstream service, a follow-up in a different repo, anything the box you are in cannot do itself. ${NOT_A_RUN}`,
|
|
3411
|
+
"",
|
|
3412
|
+
"Inputs:",
|
|
3413
|
+
' - project_id: numeric project id or "prj_\u2026" publicId.',
|
|
3414
|
+
" - title: one line, \u2264200 chars. This is what the backlog shows.",
|
|
3415
|
+
" - body: the prompt, markdown, \u226420 000 chars. Write it for someone who was not in this conversation: what is broken, where, how it was measured, and what to watch out for.",
|
|
3416
|
+
' - service_id: the dev box to pin it to ("svc_\u2026" or numeric). Omit for a loose idea in the project backlog.',
|
|
3417
|
+
"",
|
|
3418
|
+
"Returns: { summary, data: DevTask } - `publicId` is the id to quote back to the user.",
|
|
3419
|
+
"",
|
|
3420
|
+
'Example: create_dev_task({ project_id: 26, service_id: 51, title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026" })'
|
|
3421
|
+
].join("\n"),
|
|
3422
|
+
input: {
|
|
3423
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.'),
|
|
3424
|
+
title: z17.string().min(1).max(200).describe("One-line title, \u2264200 chars."),
|
|
3425
|
+
body: z17.string().max(2e4).optional().describe("The prompt handed to the agent. Markdown, \u226420 000 chars."),
|
|
3426
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe(
|
|
3427
|
+
'Dev box to pin it to \u2014 publicId ("svc_\u2026") or numeric id. Omit for a loose idea.'
|
|
3428
|
+
)
|
|
3429
|
+
},
|
|
3430
|
+
handler: async (args, ctx) => {
|
|
3431
|
+
const teamId = await ctx.resolveTeamId();
|
|
3432
|
+
const serviceId = args.service_id === void 0 ? void 0 : await ctx.hoststack.resolveId(args.service_id, { kind: "service", teamId });
|
|
3433
|
+
const projectId = await ctx.hoststack.resolveId(args.project_id, {
|
|
3434
|
+
kind: "project",
|
|
3435
|
+
teamId
|
|
3436
|
+
});
|
|
3437
|
+
const response = await ctx.hoststack.devTasks.create(teamId, {
|
|
3438
|
+
projectId,
|
|
3439
|
+
title: args.title,
|
|
3440
|
+
...args.body ? { body: args.body } : {},
|
|
3441
|
+
...serviceId ? { serviceId } : {}
|
|
3442
|
+
});
|
|
3443
|
+
const data = shape(response.task);
|
|
3444
|
+
return respond({
|
|
3445
|
+
summary: `Filed ${response.task.publicId}: ${response.task.title}. ${NOT_A_RUN}`,
|
|
3446
|
+
data
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
defineTool({
|
|
3451
|
+
name: "update_dev_task",
|
|
3452
|
+
category: "dev-tasks",
|
|
3453
|
+
description: [
|
|
3454
|
+
"Edit a task, or move it between the two statuses a PERSON may set.",
|
|
3455
|
+
"",
|
|
3456
|
+
"When to use: to mark a task `done` once the work has landed (by hand, or in another branch - most work does not finish inside the task runner), to reopen it as an `idea`, to correct a title or prompt, or to pin a loose idea to a box.",
|
|
3457
|
+
"",
|
|
3458
|
+
"Inputs:",
|
|
3459
|
+
' - task_id: "task_\u2026" publicId or numeric id.',
|
|
3460
|
+
' - status: "done" or "idea". Only these two; `queued`/`running`/`failed`/`cancelled` belong to the runner and are rejected.',
|
|
3461
|
+
" - title / body / service_id: optional edits.",
|
|
3462
|
+
"",
|
|
3463
|
+
"Only mark a task done when it is actually resolved - the backlog is what someone reads to decide what still needs doing.",
|
|
3464
|
+
"",
|
|
3465
|
+
"Returns: { summary, data: DevTask }.",
|
|
3466
|
+
"",
|
|
3467
|
+
'Example: update_dev_task({ task_id: "task_hy1i2jdp\u2026", status: "done" }) \u2192 { data: { status: "done" } }'
|
|
3468
|
+
].join("\n"),
|
|
3469
|
+
input: {
|
|
3470
|
+
task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.'),
|
|
3471
|
+
status: z17.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
|
|
3472
|
+
title: z17.string().min(1).max(200).optional().describe("New title."),
|
|
3473
|
+
body: z17.string().max(2e4).optional().describe("New prompt body."),
|
|
3474
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).nullable().optional().describe("Pin to a dev box, or null to unpin it back to a loose idea.")
|
|
3475
|
+
},
|
|
3476
|
+
handler: async (args, ctx) => {
|
|
3477
|
+
const teamId = await ctx.resolveTeamId();
|
|
3478
|
+
const serviceId = args.service_id === void 0 || args.service_id === null ? args.service_id : await ctx.hoststack.resolveId(args.service_id, { kind: "service", teamId });
|
|
3479
|
+
const response = await ctx.hoststack.devTasks.update(teamId, args.task_id, {
|
|
3480
|
+
...args.status ? { status: args.status } : {},
|
|
3481
|
+
...args.title ? { title: args.title } : {},
|
|
3482
|
+
...args.body !== void 0 ? { body: args.body } : {},
|
|
3483
|
+
...serviceId !== void 0 ? { serviceId } : {}
|
|
3484
|
+
});
|
|
3485
|
+
const data = shape(response.task);
|
|
3486
|
+
return respond({
|
|
3487
|
+
summary: `${response.task.publicId} is now ${response.task.status}: ${response.task.title}`,
|
|
3488
|
+
data
|
|
3489
|
+
});
|
|
3490
|
+
}
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3022
3493
|
// src/tools/resource-links.ts
|
|
3023
|
-
import { z as
|
|
3494
|
+
import { z as z18 } from "zod";
|
|
3024
3495
|
var RESOURCE_LINK_TYPES = [
|
|
3025
3496
|
"database",
|
|
3026
3497
|
"object_storage",
|
|
@@ -3081,7 +3552,7 @@ defineTool({
|
|
|
3081
3552
|
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
3082
3553
|
].join("\n"),
|
|
3083
3554
|
input: {
|
|
3084
|
-
service_id:
|
|
3555
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
3085
3556
|
},
|
|
3086
3557
|
handler: async (args, ctx) => {
|
|
3087
3558
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3118,10 +3589,10 @@ defineTool({
|
|
|
3118
3589
|
'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" } }'
|
|
3119
3590
|
].join("\n"),
|
|
3120
3591
|
input: {
|
|
3121
|
-
service_id:
|
|
3122
|
-
resource_type:
|
|
3123
|
-
resource_id:
|
|
3124
|
-
alias:
|
|
3592
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3593
|
+
resource_type: z18.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
3594
|
+
resource_id: z18.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
3595
|
+
alias: z18.string().min(1).max(48).regex(
|
|
3125
3596
|
/^[A-Z][A-Z0-9_]*$/,
|
|
3126
3597
|
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
3127
3598
|
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
@@ -3157,8 +3628,8 @@ defineTool({
|
|
|
3157
3628
|
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
3158
3629
|
].join("\n"),
|
|
3159
3630
|
input: {
|
|
3160
|
-
service_id:
|
|
3161
|
-
link_id:
|
|
3631
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3632
|
+
link_id: z18.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
3162
3633
|
},
|
|
3163
3634
|
handler: async (args, ctx) => {
|
|
3164
3635
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3170,7 +3641,7 @@ defineTool({
|
|
|
3170
3641
|
});
|
|
3171
3642
|
|
|
3172
3643
|
// src/tools/services.ts
|
|
3173
|
-
import { z as
|
|
3644
|
+
import { z as z19 } from "zod";
|
|
3174
3645
|
|
|
3175
3646
|
// src/lib/app-templates.ts
|
|
3176
3647
|
var MCP_APP_TEMPLATES = [
|
|
@@ -3427,11 +3898,11 @@ defineTool({
|
|
|
3427
3898
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
3428
3899
|
].join("\n"),
|
|
3429
3900
|
input: {
|
|
3430
|
-
project_id:
|
|
3431
|
-
environment_id:
|
|
3432
|
-
status:
|
|
3433
|
-
type:
|
|
3434
|
-
dev_environment:
|
|
3901
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
3902
|
+
environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
3903
|
+
status: z19.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
3904
|
+
type: z19.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
3905
|
+
dev_environment: z19.boolean().optional().describe(
|
|
3435
3906
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
3436
3907
|
)
|
|
3437
3908
|
},
|
|
@@ -3504,29 +3975,29 @@ defineTool({
|
|
|
3504
3975
|
'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.'
|
|
3505
3976
|
].join("\n"),
|
|
3506
3977
|
input: {
|
|
3507
|
-
project_id:
|
|
3508
|
-
name:
|
|
3509
|
-
type:
|
|
3510
|
-
docker_image:
|
|
3978
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3979
|
+
name: z19.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
3980
|
+
type: z19.enum(SERVICE_TYPES).describe("Service type."),
|
|
3981
|
+
docker_image: z19.string().max(500).optional().describe(
|
|
3511
3982
|
"Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
|
|
3512
3983
|
),
|
|
3513
|
-
github_repo_id:
|
|
3514
|
-
branch:
|
|
3515
|
-
install_command:
|
|
3516
|
-
build_command:
|
|
3517
|
-
start_command:
|
|
3518
|
-
cron_schedule:
|
|
3519
|
-
publish_path:
|
|
3520
|
-
runtime:
|
|
3521
|
-
port:
|
|
3984
|
+
github_repo_id: z19.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
3985
|
+
branch: z19.string().max(200).optional().describe('Git branch (default "main").'),
|
|
3986
|
+
install_command: z19.string().max(1e3).optional().describe("Install shell command."),
|
|
3987
|
+
build_command: z19.string().max(1e3).optional().describe("Build shell command."),
|
|
3988
|
+
start_command: z19.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
3989
|
+
cron_schedule: z19.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
3990
|
+
publish_path: z19.string().max(500).optional().describe("Static-site output dir."),
|
|
3991
|
+
runtime: z19.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
3992
|
+
port: z19.number().int().min(1).max(65535).optional().describe(
|
|
3522
3993
|
"Listen port, for a prebuilt image whose port is fixed by the image. Omit for a source-built service \u2014 it binds the injected $PORT."
|
|
3523
3994
|
),
|
|
3524
|
-
template_id:
|
|
3995
|
+
template_id: z19.string().max(64).optional().describe(
|
|
3525
3996
|
"Quickstart template id from list_templates. Its volumes, scratch dirs, uid, secrets and companion database are resolved server-side; for an image template also pass its docker_image and port."
|
|
3526
3997
|
),
|
|
3527
|
-
plan:
|
|
3528
|
-
environment_id:
|
|
3529
|
-
auto_deploy:
|
|
3998
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
3999
|
+
environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
4000
|
+
auto_deploy: z19.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
|
|
3530
4001
|
machine: machineInput
|
|
3531
4002
|
},
|
|
3532
4003
|
handler: async (args, ctx) => {
|
|
@@ -3597,18 +4068,18 @@ defineTool({
|
|
|
3597
4068
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
3598
4069
|
].join("\n"),
|
|
3599
4070
|
input: {
|
|
3600
|
-
project_id:
|
|
3601
|
-
name:
|
|
3602
|
-
plan:
|
|
4071
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
4072
|
+
name: z19.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
4073
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe(
|
|
3603
4074
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
3604
4075
|
),
|
|
3605
|
-
disk_gb:
|
|
3606
|
-
hoststack_api_key:
|
|
3607
|
-
poststack_api_key:
|
|
3608
|
-
repo_url:
|
|
4076
|
+
disk_gb: z19.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
|
|
4077
|
+
hoststack_api_key: z19.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
4078
|
+
poststack_api_key: z19.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
4079
|
+
repo_url: z19.string().max(500).optional().describe(
|
|
3609
4080
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
3610
4081
|
),
|
|
3611
|
-
branch:
|
|
4082
|
+
branch: z19.string().max(200).optional().describe("Branch to clone (with repo_url)."),
|
|
3612
4083
|
machine: machineInput
|
|
3613
4084
|
},
|
|
3614
4085
|
handler: async (args, ctx) => {
|
|
@@ -3725,9 +4196,9 @@ defineTool({
|
|
|
3725
4196
|
'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.'
|
|
3726
4197
|
].join("\n"),
|
|
3727
4198
|
input: {
|
|
3728
|
-
service_id:
|
|
3729
|
-
include_database_clone:
|
|
3730
|
-
name:
|
|
4199
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
4200
|
+
include_database_clone: z19.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
4201
|
+
name: z19.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
3731
4202
|
},
|
|
3732
4203
|
handler: async (args, ctx) => {
|
|
3733
4204
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3768,7 +4239,7 @@ defineTool({
|
|
|
3768
4239
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
3769
4240
|
].join("\n"),
|
|
3770
4241
|
input: {
|
|
3771
|
-
service_id:
|
|
4242
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
3772
4243
|
},
|
|
3773
4244
|
handler: async (args, ctx) => {
|
|
3774
4245
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3791,10 +4262,12 @@ defineTool({
|
|
|
3791
4262
|
"",
|
|
3792
4263
|
"This resizes a cloud Dev Box, NOT a project deploy environment.",
|
|
3793
4264
|
"",
|
|
3794
|
-
"When to use: a Dev Box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER
|
|
4265
|
+
"When to use: a Dev Box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER.",
|
|
3795
4266
|
"",
|
|
3796
4267
|
"How it applies: the new tier's memory + CPU take effect LIVE on the running container (no recreate, no dropped shell sessions); a larger disk takes effect on the next recreate (suspend\u2192resume). Dev boxes are floored to the OOM-safe minimum size server-side.",
|
|
3797
4268
|
"",
|
|
4269
|
+
"The tier is not always the container's limit, so check `effectiveMemoryMb` from list_dev_environments rather than assuming: a dedicated (Pro) tier resolves ~1.5 GB below nominal for agent headroom, and a per-service memory override REPLACES the tier figure rather than merely clamping to it. An UPSIZE now retires an override that would hold the box below its new tier; a re-assert of the SAME tier does not, so on a box already at the top tier a resize cannot lift an override \u2014 clear it with a config PATCH of memoryMb to 512 (the legacy floor, which means \"no override\"), or use the Remove-cap button on the box's Settings \u2192 General tab. `memoryPinnedBelowTier: true` is how you tell that case apart from a box that is genuinely maxed out.",
|
|
4270
|
+
"",
|
|
3798
4271
|
"Inputs:",
|
|
3799
4272
|
" - service_id: the box to resize \u2014 numeric id or publicId.",
|
|
3800
4273
|
' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
|
|
@@ -3804,8 +4277,8 @@ defineTool({
|
|
|
3804
4277
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
3805
4278
|
].join("\n"),
|
|
3806
4279
|
input: {
|
|
3807
|
-
service_id:
|
|
3808
|
-
size:
|
|
4280
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
4281
|
+
size: z19.enum(SERVICE_PLANS).describe(
|
|
3809
4282
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
3810
4283
|
)
|
|
3811
4284
|
},
|
|
@@ -3832,7 +4305,9 @@ defineTool({
|
|
|
3832
4305
|
"",
|
|
3833
4306
|
`When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
|
|
3834
4307
|
"",
|
|
3835
|
-
'Returns: { items: [{ ...service, devUrl, databases, exitReason, recommendedSize }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]). `exitReason` is "oom_killed" / "crashed" / null for the box\'s last container exit; when it is "oom_killed", `recommendedSize` is the next tier up to rescale to (use resize_dev_environment).',
|
|
4308
|
+
'Returns: { items: [{ ...service, devUrl, databases, exitReason, recommendedSize, effectiveMemoryMb, memoryPinnedBelowTier }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]). `exitReason` is "oom_killed" / "crashed" / null for the box\'s last container exit; when it is "oom_killed", `recommendedSize` is the next tier up to rescale to (use resize_dev_environment).',
|
|
4309
|
+
"",
|
|
4310
|
+
'`effectiveMemoryMb` is the container\'s REAL ceiling \u2014 what `/sys/fs/cgroup/memory.max` reads inside the box \u2014 and is the number to size any in-box work against. Do NOT derive it from `plan`: a dedicated (Pro) tier sits ~1.5 GB below nominal, and a per-service override replaces the tier figure outright. When `memoryPinnedBelowTier` is true an override, not the tier, is the cap, so `recommendedSize: null` means "nothing bigger to sell you" rather than "nothing you can do" \u2014 see resize_dev_environment for how to lift it.',
|
|
3836
4311
|
"",
|
|
3837
4312
|
"Example: list_dev_environments() \u2192 every dev box for the active team."
|
|
3838
4313
|
].join("\n"),
|
|
@@ -3849,7 +4324,12 @@ defineTool({
|
|
|
3849
4324
|
devUrl: env.devUrl ?? null,
|
|
3850
4325
|
databases: env.databases ?? [],
|
|
3851
4326
|
exitReason: env.exitReason ?? null,
|
|
3852
|
-
recommendedSize: env.recommendedSize ?? null
|
|
4327
|
+
recommendedSize: env.recommendedSize ?? null,
|
|
4328
|
+
// The number an agent should size its work against. Absent it,
|
|
4329
|
+
// the only honest way to learn a box's ceiling was to shell in
|
|
4330
|
+
// and read the cgroup — so every box rediscovered its own.
|
|
4331
|
+
effectiveMemoryMb: env.effectiveMemoryMb ?? null,
|
|
4332
|
+
memoryPinnedBelowTier: env.memoryPinnedBelowTier ?? false
|
|
3853
4333
|
}))
|
|
3854
4334
|
}
|
|
3855
4335
|
});
|
|
@@ -3948,21 +4428,21 @@ defineTool({
|
|
|
3948
4428
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
3949
4429
|
].join("\n"),
|
|
3950
4430
|
input: {
|
|
3951
|
-
name:
|
|
4431
|
+
name: z19.string().min(1).max(100).optional().describe(
|
|
3952
4432
|
'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.'
|
|
3953
4433
|
),
|
|
3954
|
-
source_kind:
|
|
3955
|
-
github_repo_id:
|
|
3956
|
-
clone_url:
|
|
3957
|
-
branch:
|
|
3958
|
-
databases:
|
|
3959
|
-
plan:
|
|
4434
|
+
source_kind: z19.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
4435
|
+
github_repo_id: z19.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
4436
|
+
clone_url: z19.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
4437
|
+
branch: z19.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
4438
|
+
databases: z19.array(z19.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
4439
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe(
|
|
3960
4440
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
3961
4441
|
),
|
|
3962
|
-
agent_accounts:
|
|
3963
|
-
|
|
3964
|
-
provider:
|
|
3965
|
-
account_id:
|
|
4442
|
+
agent_accounts: z19.array(
|
|
4443
|
+
z19.object({
|
|
4444
|
+
provider: z19.enum(["claude", "codex", "opencode"]),
|
|
4445
|
+
account_id: z19.number().int().positive()
|
|
3966
4446
|
})
|
|
3967
4447
|
).max(3).optional().describe(
|
|
3968
4448
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
@@ -4035,12 +4515,12 @@ defineTool({
|
|
|
4035
4515
|
"Inputs:",
|
|
4036
4516
|
' - service_id: publicId of the service (e.g. "svc_abc123").',
|
|
4037
4517
|
"",
|
|
4038
|
-
'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
|
|
4518
|
+
'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, allowSearchIndexing, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
|
|
4039
4519
|
"",
|
|
4040
4520
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
4041
4521
|
].join("\n"),
|
|
4042
4522
|
input: {
|
|
4043
|
-
service_id:
|
|
4523
|
+
service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4044
4524
|
},
|
|
4045
4525
|
handler: async (args, ctx) => {
|
|
4046
4526
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4072,7 +4552,7 @@ defineTool({
|
|
|
4072
4552
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
4073
4553
|
].join("\n"),
|
|
4074
4554
|
input: {
|
|
4075
|
-
service_id:
|
|
4555
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4076
4556
|
},
|
|
4077
4557
|
handler: async (args, ctx) => {
|
|
4078
4558
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4094,16 +4574,20 @@ defineTool({
|
|
|
4094
4574
|
' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
|
|
4095
4575
|
" - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
|
|
4096
4576
|
"",
|
|
4097
|
-
"Resolution: \u22647d \u2192 raw samples (~
|
|
4577
|
+
"Resolution: \u22647d \u2192 raw samples (one per ~30s), >7d \u2192 hourly pre-aggregates, >30d \u2192 daily. At most 300 points are returned, so a window wider than ~2.5h is thinned to fit.",
|
|
4578
|
+
"",
|
|
4579
|
+
"`cpuPercent` is the PEAK of whatever the point covers, and 100 means one host CORE \u2014 not 100% of the service. A `micro` (500 millicores) is capped by its cgroup at ~50, so ~50 IS that plan pegged. To compare against a `service.pressure_sustained` alert, which reports a share of the plan allowance, multiply by 1000/cpuMillicores (the alert ships that denominator in its metadata).",
|
|
4580
|
+
"",
|
|
4581
|
+
"Memory is the reading at that point, NOT a peak \u2014 so a memory number here can sit below an alert that averaged the hour.",
|
|
4098
4582
|
"",
|
|
4099
4583
|
"Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
|
|
4100
4584
|
"",
|
|
4101
|
-
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192
|
|
4585
|
+
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 ~120 points for the last hour.'
|
|
4102
4586
|
].join("\n"),
|
|
4103
4587
|
input: {
|
|
4104
|
-
service_id:
|
|
4105
|
-
from:
|
|
4106
|
-
to:
|
|
4588
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4589
|
+
from: z19.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
4590
|
+
to: z19.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
4107
4591
|
},
|
|
4108
4592
|
handler: async (args, ctx) => {
|
|
4109
4593
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4147,8 +4631,8 @@ defineTool({
|
|
|
4147
4631
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
4148
4632
|
].join("\n"),
|
|
4149
4633
|
input: {
|
|
4150
|
-
service_id:
|
|
4151
|
-
name:
|
|
4634
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4635
|
+
name: z19.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
4152
4636
|
},
|
|
4153
4637
|
handler: async (args, ctx) => {
|
|
4154
4638
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4176,6 +4660,7 @@ defineTool({
|
|
|
4176
4660
|
" - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
|
|
4177
4661
|
' - health_check_path (optional): HTTP path the platform GETs to verify liveness (e.g. "/health"). Pass null for TCP-only check.',
|
|
4178
4662
|
" - health_check_enabled (optional): boolean \u2014 toggle health checking on/off.",
|
|
4663
|
+
" - allow_search_indexing (optional): boolean \u2014 let search engines index the free *.hoststack.dev platform URL. Off by default; the platform URL is served with X-Robots-Tag: noindex so a site does not rank on a hostname it does not own. Custom domains are always indexable and unaffected. Applies on the next deploy.",
|
|
4179
4664
|
" - health_check_interval (optional): integer 5\u2013300 seconds \u2014 how often the check runs.",
|
|
4180
4665
|
" - health_check_timeout (optional): integer 1\u201360 seconds \u2014 single-attempt timeout.",
|
|
4181
4666
|
' - health_check_grace_period_sec (optional): integer 1\u20131800 seconds \u2014 startup tolerance before failures count. RAISE THIS (e.g. 180) when the agent reports "Health check timed out" on a cold-boot app (Bun + Vite SSR typically need 90\u2013180s).',
|
|
@@ -4190,47 +4675,50 @@ defineTool({
|
|
|
4190
4675
|
" - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
|
|
4191
4676
|
" - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
|
|
4192
4677
|
" - scale_cpu_threshold, scale_memory_threshold (optional): integer 10\u2013100 \u2014 autoscale trigger percentage.",
|
|
4193
|
-
|
|
4678
|
+
` - log_filter_rules (optional): list of { pattern, action } rules applied at read time \u2014 to get_service_logs (tail AND count) and to the dashboard's live stream alike. Pattern matches the message by case-insensitive substring, literally (a % or _ is not a wildcard); action is "drop" (filter out) or "downgrade" (flip stderr \u2192 stdout so it stops looking like an error). Stored lines are never altered, so removing a rule re-exposes everything it was hiding. Pass [] to clear all rules. Capped at 50 rules.`,
|
|
4194
4679
|
"",
|
|
4195
4680
|
"Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
|
|
4196
4681
|
"",
|
|
4197
4682
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
4198
4683
|
].join("\n"),
|
|
4199
4684
|
input: {
|
|
4200
|
-
service_id:
|
|
4201
|
-
install_command:
|
|
4202
|
-
build_command:
|
|
4203
|
-
start_command:
|
|
4204
|
-
branch:
|
|
4205
|
-
root_directory:
|
|
4206
|
-
dockerfile_path:
|
|
4207
|
-
auto_deploy:
|
|
4208
|
-
health_check_path:
|
|
4209
|
-
health_check_enabled:
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4685
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4686
|
+
install_command: z19.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
4687
|
+
build_command: z19.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
4688
|
+
start_command: z19.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
4689
|
+
branch: z19.string().optional().describe("Git branch to track."),
|
|
4690
|
+
root_directory: z19.string().optional().describe("Build context root."),
|
|
4691
|
+
dockerfile_path: z19.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
4692
|
+
auto_deploy: z19.boolean().optional().describe("Auto-deploy on push."),
|
|
4693
|
+
health_check_path: z19.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
4694
|
+
health_check_enabled: z19.boolean().optional().describe("Toggle health checking on/off."),
|
|
4695
|
+
allow_search_indexing: z19.boolean().optional().describe(
|
|
4696
|
+
"Let search engines index the free *.hoststack.dev platform URL (off by default). Custom domains are always indexable. Applies on the next deploy."
|
|
4697
|
+
),
|
|
4698
|
+
health_check_interval: z19.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
4699
|
+
health_check_timeout: z19.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
4700
|
+
health_check_grace_period_sec: z19.number().int().min(1).max(1800).optional().describe(
|
|
4213
4701
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
4214
4702
|
),
|
|
4215
|
-
memory_mb:
|
|
4216
|
-
cpu_shares:
|
|
4217
|
-
disk_size_gb:
|
|
4218
|
-
port:
|
|
4219
|
-
protocol:
|
|
4220
|
-
restart_policy:
|
|
4221
|
-
deploy_strategy:
|
|
4703
|
+
memory_mb: z19.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
4704
|
+
cpu_shares: z19.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
4705
|
+
disk_size_gb: z19.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
4706
|
+
port: z19.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
4707
|
+
protocol: z19.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
4708
|
+
restart_policy: z19.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
4709
|
+
deploy_strategy: z19.enum(["rolling", "recreate"]).optional().describe(
|
|
4222
4710
|
'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.'
|
|
4223
4711
|
),
|
|
4224
|
-
pre_deploy_command:
|
|
4225
|
-
instance_count:
|
|
4226
|
-
min_instances:
|
|
4227
|
-
max_instances:
|
|
4228
|
-
scale_cpu_threshold:
|
|
4229
|
-
scale_memory_threshold:
|
|
4230
|
-
log_filter_rules:
|
|
4231
|
-
|
|
4232
|
-
pattern:
|
|
4233
|
-
action:
|
|
4712
|
+
pre_deploy_command: z19.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
4713
|
+
instance_count: z19.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
4714
|
+
min_instances: z19.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
4715
|
+
max_instances: z19.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
4716
|
+
scale_cpu_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
4717
|
+
scale_memory_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
4718
|
+
log_filter_rules: z19.array(
|
|
4719
|
+
z19.object({
|
|
4720
|
+
pattern: z19.string().min(1).max(200),
|
|
4721
|
+
action: z19.enum(["drop", "downgrade"])
|
|
4234
4722
|
})
|
|
4235
4723
|
).max(50).optional().describe(
|
|
4236
4724
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -4253,6 +4741,8 @@ defineTool({
|
|
|
4253
4741
|
const configUpdate = {};
|
|
4254
4742
|
if (args.health_check_enabled !== void 0)
|
|
4255
4743
|
configUpdate["healthCheckEnabled"] = args.health_check_enabled;
|
|
4744
|
+
if (args.allow_search_indexing !== void 0)
|
|
4745
|
+
configUpdate["allowSearchIndexing"] = args.allow_search_indexing;
|
|
4256
4746
|
if (args.health_check_interval !== void 0)
|
|
4257
4747
|
configUpdate["healthCheckInterval"] = args.health_check_interval;
|
|
4258
4748
|
if (args.health_check_timeout !== void 0)
|
|
@@ -4327,7 +4817,7 @@ defineTool({
|
|
|
4327
4817
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
4328
4818
|
].join("\n"),
|
|
4329
4819
|
input: {
|
|
4330
|
-
service_id:
|
|
4820
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4331
4821
|
},
|
|
4332
4822
|
handler: async (args, ctx) => {
|
|
4333
4823
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4351,7 +4841,7 @@ defineTool({
|
|
|
4351
4841
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
4352
4842
|
].join("\n"),
|
|
4353
4843
|
input: {
|
|
4354
|
-
service_id:
|
|
4844
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4355
4845
|
},
|
|
4356
4846
|
handler: async (args, ctx) => {
|
|
4357
4847
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4377,7 +4867,7 @@ defineTool({
|
|
|
4377
4867
|
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
4378
4868
|
].join("\n"),
|
|
4379
4869
|
input: {
|
|
4380
|
-
service_id:
|
|
4870
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4381
4871
|
},
|
|
4382
4872
|
handler: async (args, ctx) => {
|
|
4383
4873
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4403,7 +4893,9 @@ defineTool({
|
|
|
4403
4893
|
' - stream (optional): "stdout" | "stderr". Omit to combine.',
|
|
4404
4894
|
" - level (optional): real log level \u2014 trace/debug/info/warn/error/fatal. For structured JSON logs (pino, bunyan, OpenTelemetry severity) this filters on the parsed inner level field. For plain text logs it falls back to a stream-alias hint (info/debug \u2192 stdout, warn/error/fatal \u2192 stderr).",
|
|
4405
4895
|
" - search (optional): case-insensitive substring grep, \u2264100 chars.",
|
|
4406
|
-
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
|
|
4896
|
+
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling. It counts exactly what the same call would return, so `count` and the entry count agree whenever count <= lines.',
|
|
4897
|
+
"",
|
|
4898
|
+
"If the service has log_filter_rules with action='drop' (see update_service_config), the lines they match are excluded from BOTH the tail and the count \u2014 so a count lower than the raw log volume is the rules working, not lines going missing. `stream` selects on the stream shown in the response, which a 'downgrade' rule may have flipped from stderr to stdout.",
|
|
4407
4899
|
"",
|
|
4408
4900
|
"Returns: { logs: LogEntry[] | string } when count_only is false. Each entry has { timestamp, level?, stream, message }. `level` is the parsed inner level when the message is a structured JSON envelope (pino numeric or string), and undefined for plain-text logs. `stream` is always one of stdout/stderr. Or { count: number } when count_only is true.",
|
|
4409
4901
|
"",
|
|
@@ -4414,16 +4906,16 @@ defineTool({
|
|
|
4414
4906
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
4415
4907
|
].join("\n"),
|
|
4416
4908
|
input: {
|
|
4417
|
-
service_id:
|
|
4418
|
-
lines:
|
|
4419
|
-
since:
|
|
4420
|
-
until:
|
|
4421
|
-
stream:
|
|
4422
|
-
level:
|
|
4909
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4910
|
+
lines: z19.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
4911
|
+
since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4912
|
+
until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4913
|
+
stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4914
|
+
level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
4423
4915
|
"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)."
|
|
4424
4916
|
),
|
|
4425
|
-
search:
|
|
4426
|
-
count_only:
|
|
4917
|
+
search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4918
|
+
count_only: z19.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
4427
4919
|
},
|
|
4428
4920
|
handler: async (args, ctx) => {
|
|
4429
4921
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4472,14 +4964,14 @@ defineTool({
|
|
|
4472
4964
|
'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 } } }.'
|
|
4473
4965
|
].join("\n"),
|
|
4474
4966
|
input: {
|
|
4475
|
-
service_ids:
|
|
4476
|
-
lines_per_service:
|
|
4477
|
-
since:
|
|
4478
|
-
until:
|
|
4479
|
-
stream:
|
|
4480
|
-
level:
|
|
4481
|
-
search:
|
|
4482
|
-
count_only:
|
|
4967
|
+
service_ids: z19.array(z19.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
4968
|
+
lines_per_service: z19.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
4969
|
+
since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4970
|
+
until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4971
|
+
stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4972
|
+
level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
4973
|
+
search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4974
|
+
count_only: z19.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
4483
4975
|
},
|
|
4484
4976
|
handler: async (args, ctx) => {
|
|
4485
4977
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4525,7 +5017,7 @@ defineTool({
|
|
|
4525
5017
|
});
|
|
4526
5018
|
|
|
4527
5019
|
// src/tools/uptime.ts
|
|
4528
|
-
import { z as
|
|
5020
|
+
import { z as z20 } from "zod";
|
|
4529
5021
|
var STATUS_MEANING = [
|
|
4530
5022
|
"Status vocabulary: `up` answering as expected \xB7 `down` failed the threshold and an alert is open \xB7 `unknown` not probed yet \xB7 `unresolvable` the service has no active domain to request \xB7 `paused` the service is not meant to be answering (suspended, mid-deploy, never deployed) OR it sleeps when idle and probing it would keep it awake."
|
|
4531
5023
|
].join("\n");
|
|
@@ -4541,11 +5033,13 @@ defineTool({
|
|
|
4541
5033
|
"",
|
|
4542
5034
|
"Inputs: serviceId (required).",
|
|
4543
5035
|
"",
|
|
4544
|
-
'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
|
|
5036
|
+
'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), and lastTargetUrl.',
|
|
5037
|
+
"",
|
|
5038
|
+
"WHICH HOSTNAME IS PROBED: `lastTargetUrl` is the URL the last probe actually dialled \u2014 read it here rather than inferring it. A check names a PATH and the platform picks the host: the service's primary domain, or the OLDEST domain when no primary is nominated, which on a renamed host is usually the retired alias. So a service answering on two names that behave differently (canonical serves, alias 301s) can have a check that asserts the wrong status code without anything being wrong with the service. If lastTargetUrl is not the name you meant to watch, nominate the right one with update_domain({ domain_id, isPrimary: true }) \u2014 set_uptime_check takes a path and never a host.",
|
|
4545
5039
|
"",
|
|
4546
|
-
"Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
|
|
5040
|
+
"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', lastTargetUrl: 'https://grundfast.dk/healthz' } }."
|
|
4547
5041
|
].join("\n"),
|
|
4548
|
-
input: { serviceId:
|
|
5042
|
+
input: { serviceId: z20.number().int().positive() },
|
|
4549
5043
|
handler: async (args, ctx) => {
|
|
4550
5044
|
const teamId = await ctx.resolveTeamId();
|
|
4551
5045
|
const response = await ctx.api.get(
|
|
@@ -4574,9 +5068,11 @@ defineTool({
|
|
|
4574
5068
|
"",
|
|
4575
5069
|
'Only service types with a public URL can be checked (web services and static sites). A worker, cron job or private service is refused with 400 \u2014 a check on one could only ever report "no domain to check", which reads as a broken feature rather than an inapplicable one.',
|
|
4576
5070
|
"",
|
|
4577
|
-
"
|
|
5071
|
+
"For a site HostStack does NOT host, there is no serviceId to pass: use set_site_uptime_check with its analytics siteId instead. That path needs the domain proven first (verify_site_domain), because the host it probes comes from the site row rather than from a domain the platform already vouches for.",
|
|
4578
5072
|
"",
|
|
4579
|
-
"
|
|
5073
|
+
"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. A write that resolves to the shape already stored changes nothing and leaves that state alone \u2014 but the inputs are defaults, not a partial edit, so omitting intervalSeconds on a check set to 30 resolves to 60 and IS a change. Read a check with get_uptime_check; never re-save one in order to look at it.",
|
|
5074
|
+
"",
|
|
5075
|
+
"`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. On a service with SEVERAL domains that is a choice, and this tool does not make it \u2014 nominate the hostname to watch with update_domain({ domain_id, isPrimary: true }), or the platform falls back to the OLDEST domain, which after a rename is usually the alias that redirects. Check which one is live with get_uptime_check's lastTargetUrl before trusting an expectedStatus. When ONE named hostname has to be asserted whatever the platform picks \u2014 a canonical host and a 301 alias cannot share an expectedStatus \u2014 give that hostname its own analytics site and check it with set_site_uptime_check.",
|
|
4580
5076
|
"",
|
|
4581
5077
|
"Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
|
|
4582
5078
|
"",
|
|
@@ -4589,14 +5085,14 @@ defineTool({
|
|
|
4589
5085
|
"Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
4590
5086
|
].join("\n"),
|
|
4591
5087
|
input: {
|
|
4592
|
-
serviceId:
|
|
4593
|
-
enabled:
|
|
4594
|
-
path:
|
|
4595
|
-
method:
|
|
4596
|
-
expectedStatus:
|
|
4597
|
-
timeoutMs:
|
|
4598
|
-
intervalSeconds:
|
|
4599
|
-
failureThreshold:
|
|
5088
|
+
serviceId: z20.number().int().positive(),
|
|
5089
|
+
enabled: z20.boolean().optional(),
|
|
5090
|
+
path: z20.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
5091
|
+
method: z20.enum(["GET", "HEAD"]).optional(),
|
|
5092
|
+
expectedStatus: z20.number().int().min(100).max(599).optional(),
|
|
5093
|
+
timeoutMs: z20.number().int().min(1e3).max(6e4).optional(),
|
|
5094
|
+
intervalSeconds: z20.number().int().min(30).max(3600).optional(),
|
|
5095
|
+
failureThreshold: z20.number().int().min(1).max(10).optional()
|
|
4600
5096
|
},
|
|
4601
5097
|
handler: async (args, ctx) => {
|
|
4602
5098
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4605,9 +5101,11 @@ defineTool({
|
|
|
4605
5101
|
`/api/services/${teamId}/${serviceId}/uptime-check`,
|
|
4606
5102
|
body
|
|
4607
5103
|
);
|
|
5104
|
+
const check = shape(response.check);
|
|
5105
|
+
const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
|
|
4608
5106
|
return respond({
|
|
4609
|
-
summary: `Uptime check saved. It
|
|
4610
|
-
data: { check
|
|
5107
|
+
summary: probed ? `Uptime check saved \u2014 the shape was already stored, so nothing was reset: still ${String(check["status"])}, last probed ${String(check["lastCheckedAt"])}.` : 'Uptime check saved, and not probed yet \u2014 "unknown" with no lastCheckedAt is the state of a check that has not run once, not a broken one. It reports within a minute or two: read it with get_uptime_check, never by saving it again.',
|
|
5108
|
+
data: { check }
|
|
4611
5109
|
});
|
|
4612
5110
|
}
|
|
4613
5111
|
});
|
|
@@ -4625,7 +5123,7 @@ defineTool({
|
|
|
4625
5123
|
"",
|
|
4626
5124
|
"Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
|
|
4627
5125
|
].join("\n"),
|
|
4628
|
-
input: { serviceId:
|
|
5126
|
+
input: { serviceId: z20.number().int().positive() },
|
|
4629
5127
|
handler: async (args, ctx) => {
|
|
4630
5128
|
const teamId = await ctx.resolveTeamId();
|
|
4631
5129
|
await ctx.api.delete(`/api/services/${teamId}/${args.serviceId}/uptime-check`);
|
|
@@ -4634,7 +5132,7 @@ defineTool({
|
|
|
4634
5132
|
});
|
|
4635
5133
|
|
|
4636
5134
|
// src/tools/volumes.ts
|
|
4637
|
-
import { z as
|
|
5135
|
+
import { z as z21 } from "zod";
|
|
4638
5136
|
var MIN_VOLUME_SIZE_GB = 10;
|
|
4639
5137
|
defineTool({
|
|
4640
5138
|
name: "list_volumes",
|
|
@@ -4652,7 +5150,7 @@ defineTool({
|
|
|
4652
5150
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
4653
5151
|
].join("\n"),
|
|
4654
5152
|
input: {
|
|
4655
|
-
service_id:
|
|
5153
|
+
service_id: z21.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4656
5154
|
},
|
|
4657
5155
|
handler: async (args, ctx) => {
|
|
4658
5156
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4681,15 +5179,15 @@ defineTool({
|
|
|
4681
5179
|
'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" } }'
|
|
4682
5180
|
].join("\n"),
|
|
4683
5181
|
input: {
|
|
4684
|
-
service_id:
|
|
4685
|
-
name:
|
|
4686
|
-
mount_path:
|
|
5182
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5183
|
+
name: z21.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
5184
|
+
mount_path: z21.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
4687
5185
|
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
4688
5186
|
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
4689
5187
|
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
4690
5188
|
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
4691
5189
|
// size. Reject it at the call instead.
|
|
4692
|
-
size_gb:
|
|
5190
|
+
size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
4693
5191
|
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
4694
5192
|
)
|
|
4695
5193
|
},
|
|
@@ -4727,10 +5225,10 @@ defineTool({
|
|
|
4727
5225
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
4728
5226
|
].join("\n"),
|
|
4729
5227
|
input: {
|
|
4730
|
-
service_id:
|
|
4731
|
-
volume_id:
|
|
4732
|
-
mount_path:
|
|
4733
|
-
size_gb:
|
|
5228
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5229
|
+
volume_id: z21.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
5230
|
+
mount_path: z21.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
5231
|
+
size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
|
|
4734
5232
|
},
|
|
4735
5233
|
handler: async (args, ctx) => {
|
|
4736
5234
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4768,8 +5266,8 @@ defineTool({
|
|
|
4768
5266
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
4769
5267
|
].join("\n"),
|
|
4770
5268
|
input: {
|
|
4771
|
-
service_id:
|
|
4772
|
-
volume_id:
|
|
5269
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5270
|
+
volume_id: z21.string().describe("Volume publicId.")
|
|
4773
5271
|
},
|
|
4774
5272
|
handler: async (args, ctx) => {
|
|
4775
5273
|
const teamId = await ctx.resolveTeamId();
|