@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/hoststack-mcp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
8
8
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
9
9
|
|
|
10
10
|
// src/version.ts
|
|
11
|
-
var MCP_VERSION = true ? "0.
|
|
11
|
+
var MCP_VERSION = true ? "0.20.0" : "0.0.0-dev";
|
|
12
12
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
13
13
|
|
|
14
14
|
// src/api-client.ts
|
|
@@ -1393,7 +1393,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1393
1393
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1394
1394
|
if (input.zone_id) {
|
|
1395
1395
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1396
|
-
const match = zones2.find((
|
|
1396
|
+
const match = zones2.find((z22) => z22.publicId === input.zone_id);
|
|
1397
1397
|
if (!match) {
|
|
1398
1398
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1399
1399
|
}
|
|
@@ -1407,7 +1407,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1407
1407
|
const labels = fqdn.split(".");
|
|
1408
1408
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1409
1409
|
const candidate = labels.slice(i).join(".");
|
|
1410
|
-
const match = zones.find((
|
|
1410
|
+
const match = zones.find((z22) => z22.domainName.toLowerCase() === candidate);
|
|
1411
1411
|
if (match && match.status !== "deleting") {
|
|
1412
1412
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1413
1413
|
}
|
|
@@ -1723,9 +1723,11 @@ defineTool({
|
|
|
1723
1723
|
"",
|
|
1724
1724
|
"When to use: enumerate live domains, audit DNS verification status, or find which service a hostname resolves to before troubleshooting routing.",
|
|
1725
1725
|
"",
|
|
1726
|
-
"Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, createdAt.",
|
|
1726
|
+
"Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, isPrimary, createdAt.",
|
|
1727
1727
|
"",
|
|
1728
|
-
|
|
1728
|
+
"`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.",
|
|
1729
|
+
"",
|
|
1730
|
+
'Example: list_domains() \u2192 { items: [{ hostname: "api.example.com", verified: true, sslStatus: "active", isPrimary: false, \u2026 }] }'
|
|
1729
1731
|
].join("\n"),
|
|
1730
1732
|
input: {},
|
|
1731
1733
|
handler: async (_args, ctx) => {
|
|
@@ -1801,6 +1803,54 @@ defineTool({
|
|
|
1801
1803
|
});
|
|
1802
1804
|
}
|
|
1803
1805
|
});
|
|
1806
|
+
defineTool({
|
|
1807
|
+
name: "update_domain",
|
|
1808
|
+
category: "domains",
|
|
1809
|
+
description: [
|
|
1810
|
+
"Change a domain's settings \u2014 most usefully, nominate it as the service's PRIMARY hostname.",
|
|
1811
|
+
"",
|
|
1812
|
+
"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.",
|
|
1813
|
+
"",
|
|
1814
|
+
"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.",
|
|
1815
|
+
"",
|
|
1816
|
+
"One primary per service: promoting a domain demotes its sibling in the same write, so there is never a pair to choose between.",
|
|
1817
|
+
"",
|
|
1818
|
+
"Inputs:",
|
|
1819
|
+
" - domain_id: publicId of the domain (from list_domains).",
|
|
1820
|
+
" - isPrimary: make this the service's primary hostname.",
|
|
1821
|
+
" - sslEnabled: serve it over https. Applies on the next deploy, which this triggers.",
|
|
1822
|
+
" - redirectTo: send every request to an absolute http(s) URL instead of the service. Pass null to clear.",
|
|
1823
|
+
"",
|
|
1824
|
+
"Returns: { domain } \u2014 the updated row, including isPrimary.",
|
|
1825
|
+
"",
|
|
1826
|
+
'Example: update_domain({ domain_id: "dom_xyz", isPrimary: true }) \u2192 { domain: { hostname: "shop.example.com", isPrimary: true } }'
|
|
1827
|
+
].join("\n"),
|
|
1828
|
+
input: {
|
|
1829
|
+
domain_id: z9.string().describe("Domain publicId."),
|
|
1830
|
+
isPrimary: z9.boolean().optional(),
|
|
1831
|
+
sslEnabled: z9.boolean().optional(),
|
|
1832
|
+
redirectTo: z9.string().max(2e3).nullable().optional().describe("Absolute http(s) URL, or null to clear.")
|
|
1833
|
+
},
|
|
1834
|
+
handler: async (args2, ctx) => {
|
|
1835
|
+
const { domain_id: domainId, ...patch } = args2;
|
|
1836
|
+
const body = Object.fromEntries(
|
|
1837
|
+
Object.entries(patch).filter(([, v]) => v !== void 0)
|
|
1838
|
+
);
|
|
1839
|
+
if (Object.keys(body).length === 0) {
|
|
1840
|
+
return respond({
|
|
1841
|
+
summary: "Nothing to change \u2014 pass isPrimary, sslEnabled or redirectTo.",
|
|
1842
|
+
data: { ok: false }
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
const teamId = await ctx.resolveTeamId();
|
|
1846
|
+
const response = await ctx.hoststack.domains.update(teamId, domainId, body);
|
|
1847
|
+
const domain = shapeDomain(response.domain);
|
|
1848
|
+
return respond({
|
|
1849
|
+
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)}.`,
|
|
1850
|
+
data: { domain }
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1804
1854
|
defineTool({
|
|
1805
1855
|
name: "remove_domain",
|
|
1806
1856
|
category: "domains",
|
|
@@ -1868,6 +1918,7 @@ defineTool({
|
|
|
1868
1918
|
" - value: new value (will be encrypted at rest if is_secret=true).",
|
|
1869
1919
|
" - 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.",
|
|
1870
1920
|
' - 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.',
|
|
1921
|
+
' 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.',
|
|
1871
1922
|
"",
|
|
1872
1923
|
'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
|
|
1873
1924
|
"",
|
|
@@ -1878,7 +1929,9 @@ defineTool({
|
|
|
1878
1929
|
key: z10.string().min(1).max(128).describe("Env-var key."),
|
|
1879
1930
|
value: z10.string().describe("New value."),
|
|
1880
1931
|
is_secret: z10.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
|
|
1881
|
-
target: z10.enum(["build", "runtime", "both"]).optional().describe(
|
|
1932
|
+
target: z10.enum(["build", "runtime", "both"]).optional().describe(
|
|
1933
|
+
'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.'
|
|
1934
|
+
)
|
|
1882
1935
|
},
|
|
1883
1936
|
handler: async (args2, ctx) => {
|
|
1884
1937
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1961,6 +2014,7 @@ defineTool({
|
|
|
1961
2014
|
"Inputs:",
|
|
1962
2015
|
" - service_id: publicId of the service.",
|
|
1963
2016
|
' - 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.',
|
|
2017
|
+
' 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.',
|
|
1964
2018
|
"",
|
|
1965
2019
|
"Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
|
|
1966
2020
|
"",
|
|
@@ -2152,6 +2206,15 @@ defineTool({
|
|
|
2152
2206
|
|
|
2153
2207
|
// src/tools/analytics.ts
|
|
2154
2208
|
import { z as z12 } from "zod";
|
|
2209
|
+
|
|
2210
|
+
// src/lib/format.ts
|
|
2211
|
+
var MCP_LOCALE = "en-IE";
|
|
2212
|
+
var COUNT_FORMAT = new Intl.NumberFormat(MCP_LOCALE);
|
|
2213
|
+
function formatCount(value) {
|
|
2214
|
+
return COUNT_FORMAT.format(value);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// src/tools/analytics.ts
|
|
2155
2218
|
async function resolveSiteIds(domains, teamId, api) {
|
|
2156
2219
|
if (!domains || domains.length === 0) return void 0;
|
|
2157
2220
|
const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
|
|
@@ -2170,6 +2233,10 @@ async function resolveSiteIds(domains, teamId, api) {
|
|
|
2170
2233
|
}
|
|
2171
2234
|
return ids.join(",");
|
|
2172
2235
|
}
|
|
2236
|
+
var ALLOWED_ORIGINS_INPUT = z12.array(z12.string().trim().min(1).max(253)).max(20).describe(
|
|
2237
|
+
'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.'
|
|
2238
|
+
);
|
|
2239
|
+
var RETENTION_DAYS_INPUT = z12.number().int().positive().describe("How many days of raw events to keep. Rollups outlive this.");
|
|
2173
2240
|
defineTool({
|
|
2174
2241
|
name: "list_analytics_sites",
|
|
2175
2242
|
category: "analytics",
|
|
@@ -2205,7 +2272,9 @@ defineTool({
|
|
|
2205
2272
|
"",
|
|
2206
2273
|
"The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
|
|
2207
2274
|
"",
|
|
2208
|
-
"Returns health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
|
|
2275
|
+
"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.",
|
|
2276
|
+
"",
|
|
2277
|
+
"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.",
|
|
2209
2278
|
"",
|
|
2210
2279
|
"health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
|
|
2211
2280
|
"",
|
|
@@ -2237,6 +2306,8 @@ defineTool({
|
|
|
2237
2306
|
"",
|
|
2238
2307
|
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
|
|
2239
2308
|
"",
|
|
2309
|
+
"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.",
|
|
2310
|
+
"",
|
|
2240
2311
|
"Example: get_analytics_summary({ range: '30d' }) \u2192 { range: '30d', sites: [{ domain: 'hoststack.dev', current: { visitors: 4210, pageviews: 11890, bounceRate: 47.2 }, previous: {\u2026}, live: 3, visitorsAreSummedDailies: false }] }."
|
|
2241
2312
|
].join("\n"),
|
|
2242
2313
|
input: {
|
|
@@ -2268,6 +2339,8 @@ defineTool({
|
|
|
2268
2339
|
"",
|
|
2269
2340
|
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site), filters (optional map \u2014 country, browser, os, device, language, path, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent).",
|
|
2270
2341
|
"",
|
|
2342
|
+
"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.",
|
|
2343
|
+
"",
|
|
2271
2344
|
"Example: get_analytics_overview({ domains: ['hoststack.dev'], range: '7d', filters: { country: 'DK' } }) \u2192 { source: 'raw', filtersSupported: true, summary: { current: { visitors: 310, \u2026 } }, topPaths: [{ path: '/pricing', pageviews: 214 }], topReferrers: [{ referrer: 'Direct', visits: 190 }], \u2026 }."
|
|
2272
2345
|
].join("\n"),
|
|
2273
2346
|
input: {
|
|
@@ -2295,7 +2368,7 @@ defineTool({
|
|
|
2295
2368
|
"the filters you passed were NOT applied \u2014 they only work on shorter ranges"
|
|
2296
2369
|
);
|
|
2297
2370
|
}
|
|
2298
|
-
const summary = `${current.pageviews
|
|
2371
|
+
const summary = `${formatCount(current.pageviews)} pageviews from ${formatCount(current.visitors)} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
|
|
2299
2372
|
return respond({ summary, data: response });
|
|
2300
2373
|
}
|
|
2301
2374
|
});
|
|
@@ -2309,19 +2382,27 @@ defineTool({
|
|
|
2309
2382
|
"",
|
|
2310
2383
|
"If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
|
|
2311
2384
|
"",
|
|
2312
|
-
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
|
|
2385
|
+
'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).',
|
|
2386
|
+
"",
|
|
2387
|
+
"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.",
|
|
2388
|
+
"",
|
|
2389
|
+
"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.",
|
|
2313
2390
|
"",
|
|
2314
2391
|
`Example: create_analytics_site({ domain: 'micci.dk' }) \u2192 { site: { id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026' }, snippet: '<script defer src="https://hoststack.dev/t.js" data-site-key="site_\u2026"></script>' }.`
|
|
2315
2392
|
].join("\n"),
|
|
2316
2393
|
input: {
|
|
2317
2394
|
domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
|
|
2318
|
-
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
|
|
2395
|
+
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain."),
|
|
2396
|
+
allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
|
|
2397
|
+
retention_days: RETENTION_DAYS_INPUT.optional()
|
|
2319
2398
|
},
|
|
2320
2399
|
handler: async (args2, ctx) => {
|
|
2321
2400
|
const teamId = await ctx.resolveTeamId();
|
|
2322
2401
|
const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
|
|
2323
2402
|
domain: args2.domain,
|
|
2324
|
-
...args2.name ? { name: args2.name } : {}
|
|
2403
|
+
...args2.name ? { name: args2.name } : {},
|
|
2404
|
+
...args2.allowed_origins ? { allowedOrigins: args2.allowed_origins } : {},
|
|
2405
|
+
...args2.retention_days ? { retentionDays: args2.retention_days } : {}
|
|
2325
2406
|
});
|
|
2326
2407
|
const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
|
|
2327
2408
|
return respond({
|
|
@@ -2330,6 +2411,181 @@ defineTool({
|
|
|
2330
2411
|
});
|
|
2331
2412
|
}
|
|
2332
2413
|
});
|
|
2414
|
+
defineTool({
|
|
2415
|
+
name: "update_analytics_site",
|
|
2416
|
+
category: "analytics",
|
|
2417
|
+
description: [
|
|
2418
|
+
"Change an existing analytics site: its allowed origins, display name, retention, or which service it belongs to.",
|
|
2419
|
+
"",
|
|
2420
|
+
"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.",
|
|
2421
|
+
"",
|
|
2422
|
+
"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.",
|
|
2423
|
+
"",
|
|
2424
|
+
"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.",
|
|
2425
|
+
"",
|
|
2426
|
+
"Returns: { site } \u2014 the site as it now stands.",
|
|
2427
|
+
"",
|
|
2428
|
+
"Example: update_analytics_site({ domain: 'example.com', allowed_origins: ['staging.example.com'] }) \u2192 { site: { domain: 'example.com', allowedOrigins: ['staging.example.com'], \u2026 } }."
|
|
2429
|
+
].join("\n"),
|
|
2430
|
+
input: {
|
|
2431
|
+
domain: z12.string().min(1).max(253).describe("Bare hostname of a site this team tracks."),
|
|
2432
|
+
allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
|
|
2433
|
+
name: z12.string().trim().min(1).max(100).optional().describe("New display name."),
|
|
2434
|
+
retention_days: RETENTION_DAYS_INPUT.optional(),
|
|
2435
|
+
service_id: z12.number().int().positive().nullable().optional().describe("Numeric service id to attach this site to, or null to detach it.")
|
|
2436
|
+
},
|
|
2437
|
+
handler: async (args2, ctx) => {
|
|
2438
|
+
const teamId = await ctx.resolveTeamId();
|
|
2439
|
+
const siteId = await resolveSiteIds([args2.domain], teamId, ctx.api);
|
|
2440
|
+
const patch = {};
|
|
2441
|
+
if (args2.allowed_origins !== void 0) patch["allowedOrigins"] = args2.allowed_origins;
|
|
2442
|
+
if (args2.name !== void 0) patch["name"] = args2.name;
|
|
2443
|
+
if (args2.retention_days !== void 0) patch["retentionDays"] = args2.retention_days;
|
|
2444
|
+
if (args2.service_id !== void 0) patch["serviceId"] = args2.service_id;
|
|
2445
|
+
if (Object.keys(patch).length === 0) {
|
|
2446
|
+
throw new Error(
|
|
2447
|
+
"Nothing to update. Pass at least one of allowed_origins, name, retention_days or service_id."
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
const site = await ctx.api.patch(
|
|
2451
|
+
`/api/analytics/${teamId}/sites/${siteId}`,
|
|
2452
|
+
patch
|
|
2453
|
+
);
|
|
2454
|
+
const changed = Object.keys(patch).join(", ");
|
|
2455
|
+
return respond({
|
|
2456
|
+
summary: `Updated ${site.domain} (${changed}).`,
|
|
2457
|
+
data: { site: shape(site) }
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
defineTool({
|
|
2462
|
+
name: "verify_site_domain",
|
|
2463
|
+
category: "analytics",
|
|
2464
|
+
description: [
|
|
2465
|
+
"Prove the team owns a site's domain, by publishing a TXT record.",
|
|
2466
|
+
"",
|
|
2467
|
+
"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.",
|
|
2468
|
+
"",
|
|
2469
|
+
"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.",
|
|
2470
|
+
"",
|
|
2471
|
+
"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.",
|
|
2472
|
+
"",
|
|
2473
|
+
`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.`,
|
|
2474
|
+
"",
|
|
2475
|
+
'Inputs: siteId (required), check (optional, default false \u2014 true means "look at DNS now and tell me the verdict").',
|
|
2476
|
+
"",
|
|
2477
|
+
"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.",
|
|
2478
|
+
"",
|
|
2479
|
+
"Example: verify_site_domain({ siteId: 3 }) \u2192 { recordName: '_hoststack-verify.poststack.dev', recordType: 'TXT', recordValue: 'hoststack-verify=9f3c\u2026', verified: false }."
|
|
2480
|
+
].join("\n"),
|
|
2481
|
+
input: {
|
|
2482
|
+
siteId: z12.number().int().positive(),
|
|
2483
|
+
check: z12.boolean().optional().describe(
|
|
2484
|
+
"True to read DNS now and return the verdict. False just returns the record."
|
|
2485
|
+
)
|
|
2486
|
+
},
|
|
2487
|
+
handler: async (args2, ctx) => {
|
|
2488
|
+
const teamId = await ctx.resolveTeamId();
|
|
2489
|
+
const instructions = await ctx.api.get(`/api/analytics/${teamId}/sites/${args2.siteId}/verification`);
|
|
2490
|
+
if (!args2.check) {
|
|
2491
|
+
return respond({
|
|
2492
|
+
summary: instructions.verified ? "Already verified." : `Publish a ${instructions.recordType} record at ${instructions.recordName} with the value shown, then call again with check=true.`,
|
|
2493
|
+
data: shape(instructions)
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
const verdict = await ctx.api.post(
|
|
2497
|
+
`/api/analytics/${teamId}/sites/${args2.siteId}/verify`,
|
|
2498
|
+
{}
|
|
2499
|
+
);
|
|
2500
|
+
return respond({
|
|
2501
|
+
summary: verdict.verified ? "Verified. This site can now be given an uptime check." : `Not verified yet \u2014 ${verdict.detail ?? "the record was not found"}.`,
|
|
2502
|
+
data: { record: shape(instructions), result: shape(verdict) }
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
defineTool({
|
|
2507
|
+
name: "get_site_uptime_check",
|
|
2508
|
+
category: "analytics",
|
|
2509
|
+
description: [
|
|
2510
|
+
"Read a site's uptime check without touching it.",
|
|
2511
|
+
"",
|
|
2512
|
+
'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.',
|
|
2513
|
+
"",
|
|
2514
|
+
'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.',
|
|
2515
|
+
"",
|
|
2516
|
+
"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.",
|
|
2517
|
+
"",
|
|
2518
|
+
"Example: get_site_uptime_check({ siteId: 12 }) \u2192 { check: { status: 'up', lastStatusCode: 200, lastCheckedAt: '2026-09-09T05:27:34Z' } }."
|
|
2519
|
+
].join("\n"),
|
|
2520
|
+
input: {
|
|
2521
|
+
siteId: z12.number().int().positive()
|
|
2522
|
+
},
|
|
2523
|
+
handler: async (args2, ctx) => {
|
|
2524
|
+
const teamId = await ctx.resolveTeamId();
|
|
2525
|
+
const response = await ctx.api.get(
|
|
2526
|
+
`/api/analytics/${teamId}/sites/${args2.siteId}/uptime-check`
|
|
2527
|
+
);
|
|
2528
|
+
if (!response.check) {
|
|
2529
|
+
return respond({
|
|
2530
|
+
summary: "No uptime check on this site \u2014 nothing is watching it.",
|
|
2531
|
+
data: { check: null }
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
const check = shape(response.check);
|
|
2535
|
+
const lastCheckedAt = check.lastCheckedAt;
|
|
2536
|
+
return respond({
|
|
2537
|
+
summary: `Uptime check is ${String(check.status ?? "unknown")}${lastCheckedAt ? `, last probed ${String(lastCheckedAt)}` : ", not probed yet"}.`,
|
|
2538
|
+
data: { check }
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
});
|
|
2542
|
+
defineTool({
|
|
2543
|
+
name: "set_site_uptime_check",
|
|
2544
|
+
category: "analytics",
|
|
2545
|
+
description: [
|
|
2546
|
+
"Watch a site HostStack does not host \u2014 request its URL on a schedule and alert when it stops answering.",
|
|
2547
|
+
"",
|
|
2548
|
+
"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.",
|
|
2549
|
+
"",
|
|
2550
|
+
"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.",
|
|
2551
|
+
"",
|
|
2552
|
+
'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.',
|
|
2553
|
+
"",
|
|
2554
|
+
"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.",
|
|
2555
|
+
"",
|
|
2556
|
+
"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.",
|
|
2557
|
+
"",
|
|
2558
|
+
'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).',
|
|
2559
|
+
"",
|
|
2560
|
+
"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.",
|
|
2561
|
+
"",
|
|
2562
|
+
"Example: set_site_uptime_check({ siteId: 3, path: '/health', intervalSeconds: 60 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
2563
|
+
].join("\n"),
|
|
2564
|
+
input: {
|
|
2565
|
+
siteId: z12.number().int().positive(),
|
|
2566
|
+
enabled: z12.boolean().optional(),
|
|
2567
|
+
path: z12.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
2568
|
+
method: z12.enum(["GET", "HEAD"]).optional(),
|
|
2569
|
+
expectedStatus: z12.number().int().min(100).max(599).optional(),
|
|
2570
|
+
timeoutMs: z12.number().int().min(1e3).max(6e4).optional(),
|
|
2571
|
+
intervalSeconds: z12.number().int().min(30).max(3600).optional(),
|
|
2572
|
+
failureThreshold: z12.number().int().min(1).max(10).optional()
|
|
2573
|
+
},
|
|
2574
|
+
handler: async (args2, ctx) => {
|
|
2575
|
+
const teamId = await ctx.resolveTeamId();
|
|
2576
|
+
const { siteId, ...body } = args2;
|
|
2577
|
+
const response = await ctx.api.put(
|
|
2578
|
+
`/api/analytics/${teamId}/sites/${siteId}/uptime-check`,
|
|
2579
|
+
body
|
|
2580
|
+
);
|
|
2581
|
+
const check = shape(response.check);
|
|
2582
|
+
const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
|
|
2583
|
+
return respond({
|
|
2584
|
+
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.',
|
|
2585
|
+
data: { check }
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
});
|
|
2333
2589
|
|
|
2334
2590
|
// src/tools/errors.ts
|
|
2335
2591
|
import { z as z13 } from "zod";
|
|
@@ -2623,6 +2879,61 @@ defineTool({
|
|
|
2623
2879
|
}
|
|
2624
2880
|
});
|
|
2625
2881
|
|
|
2882
|
+
// src/tools/issue-reports.ts
|
|
2883
|
+
import { z as z14 } from "zod";
|
|
2884
|
+
defineTool({
|
|
2885
|
+
name: "report_issue",
|
|
2886
|
+
category: "support",
|
|
2887
|
+
description: [
|
|
2888
|
+
"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.",
|
|
2889
|
+
"",
|
|
2890
|
+
'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.',
|
|
2891
|
+
"",
|
|
2892
|
+
"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.",
|
|
2893
|
+
"",
|
|
2894
|
+
"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.",
|
|
2895
|
+
"",
|
|
2896
|
+
"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).",
|
|
2897
|
+
"",
|
|
2898
|
+
"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.",
|
|
2899
|
+
"",
|
|
2900
|
+
"Returns: { ticket: { id, publicId } } \u2014 quote the publicId to the user so they can follow it up.",
|
|
2901
|
+
"",
|
|
2902
|
+
"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' } }"
|
|
2903
|
+
].join("\n"),
|
|
2904
|
+
input: {
|
|
2905
|
+
title: z14.string().min(1).max(300).describe("One-line summary of the fault."),
|
|
2906
|
+
description: z14.string().min(1).max(1e4).describe("What happened, what you expected instead, and what you already ruled out."),
|
|
2907
|
+
severity: z14.enum(["low", "normal", "high", "urgent"]).optional().describe(
|
|
2908
|
+
'Default normal. Use high/urgent only when something is DOWN or losing data \u2014 not for "this is annoying".'
|
|
2909
|
+
),
|
|
2910
|
+
serviceId: z14.number().int().positive().optional().describe(
|
|
2911
|
+
"The affected service. Attaches the service, its latest deploy, and that deploy log tail automatically."
|
|
2912
|
+
),
|
|
2913
|
+
deployId: z14.number().int().positive().optional().describe("Pin a specific deploy instead of the service\u2019s most recent one."),
|
|
2914
|
+
databaseId: z14.number().int().positive().optional().describe("The affected database.")
|
|
2915
|
+
},
|
|
2916
|
+
handler: async (args2, ctx) => {
|
|
2917
|
+
const teamId = await ctx.resolveTeamId();
|
|
2918
|
+
const body = {
|
|
2919
|
+
title: args2.title,
|
|
2920
|
+
description: args2.description,
|
|
2921
|
+
severity: args2.severity ?? "normal"
|
|
2922
|
+
};
|
|
2923
|
+
if (args2.serviceId !== void 0) body["serviceId"] = args2.serviceId;
|
|
2924
|
+
if (args2.deployId !== void 0) body["deployId"] = args2.deployId;
|
|
2925
|
+
if (args2.databaseId !== void 0) body["databaseId"] = args2.databaseId;
|
|
2926
|
+
const response = await ctx.api.post(
|
|
2927
|
+
`/api/issue-reports/${teamId}`,
|
|
2928
|
+
body
|
|
2929
|
+
);
|
|
2930
|
+
return respond({
|
|
2931
|
+
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.`,
|
|
2932
|
+
data: shape(response.ticket)
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2626
2937
|
// src/tools/meta.ts
|
|
2627
2938
|
var DEV_ENV_TOOL_NAMES = [
|
|
2628
2939
|
"create_dev_environment",
|
|
@@ -2688,7 +2999,7 @@ defineTool({
|
|
|
2688
2999
|
});
|
|
2689
3000
|
|
|
2690
3001
|
// src/tools/notifications.ts
|
|
2691
|
-
import { z as
|
|
3002
|
+
import { z as z15 } from "zod";
|
|
2692
3003
|
var NOTIFICATION_EVENTS = [
|
|
2693
3004
|
"deploy.started",
|
|
2694
3005
|
"deploy.succeeded",
|
|
@@ -2699,11 +3010,17 @@ var NOTIFICATION_EVENTS = [
|
|
|
2699
3010
|
"service.suspended",
|
|
2700
3011
|
"service.resumed",
|
|
2701
3012
|
"service.restart_failed",
|
|
2702
|
-
"service.
|
|
3013
|
+
"service.no_running_container",
|
|
3014
|
+
"service.health_check_failed",
|
|
2703
3015
|
"service.acme_cert_failed",
|
|
2704
3016
|
"service.resource_alert",
|
|
3017
|
+
"service.pressure_sustained",
|
|
3018
|
+
"service.pressure_recovered",
|
|
2705
3019
|
"service.uptime_down",
|
|
2706
3020
|
"service.uptime_recovered",
|
|
3021
|
+
"watchdog.reported_down",
|
|
3022
|
+
"watchdog.reported_recovered",
|
|
3023
|
+
"watchdog.silent",
|
|
2707
3024
|
"error.issue_new",
|
|
2708
3025
|
"error.issue_regressed",
|
|
2709
3026
|
"git.auth_failed",
|
|
@@ -2715,7 +3032,13 @@ var NOTIFICATION_EVENTS = [
|
|
|
2715
3032
|
"devenv.task.needs_input",
|
|
2716
3033
|
"devenv.task.finished",
|
|
2717
3034
|
"database.backup_failed",
|
|
3035
|
+
"database.backup_overdue",
|
|
3036
|
+
"database.failed",
|
|
2718
3037
|
"database.restore_failed",
|
|
3038
|
+
"volume.backup_failed",
|
|
3039
|
+
"volume.backup_overdue",
|
|
3040
|
+
"domain.registrant_verification_lapsed",
|
|
3041
|
+
"service.auto_restarted",
|
|
2719
3042
|
"machine.offline",
|
|
2720
3043
|
"machine.online",
|
|
2721
3044
|
"billing.invoice",
|
|
@@ -2770,10 +3093,10 @@ defineTool({
|
|
|
2770
3093
|
"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'] })"
|
|
2771
3094
|
].join("\n"),
|
|
2772
3095
|
input: {
|
|
2773
|
-
type:
|
|
2774
|
-
name:
|
|
2775
|
-
webhook_url:
|
|
2776
|
-
events:
|
|
3096
|
+
type: z15.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
3097
|
+
name: z15.string().min(1).max(128).describe("Human-readable label."),
|
|
3098
|
+
webhook_url: z15.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
3099
|
+
events: z15.array(z15.enum(NOTIFICATION_EVENTS)).describe(
|
|
2777
3100
|
"List of events the channel subscribes to. Empty list = subscribe to nothing."
|
|
2778
3101
|
)
|
|
2779
3102
|
},
|
|
@@ -2811,10 +3134,10 @@ defineTool({
|
|
|
2811
3134
|
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
2812
3135
|
].join("\n"),
|
|
2813
3136
|
input: {
|
|
2814
|
-
channel_id:
|
|
2815
|
-
name:
|
|
2816
|
-
active:
|
|
2817
|
-
events:
|
|
3137
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
3138
|
+
name: z15.string().min(1).max(128).optional().describe("New label."),
|
|
3139
|
+
active: z15.boolean().optional().describe("false silences without deleting."),
|
|
3140
|
+
events: z15.array(z15.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
2818
3141
|
},
|
|
2819
3142
|
handler: async (args2, ctx) => {
|
|
2820
3143
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2853,7 +3176,7 @@ defineTool({
|
|
|
2853
3176
|
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
2854
3177
|
].join("\n"),
|
|
2855
3178
|
input: {
|
|
2856
|
-
channel_id:
|
|
3179
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id.")
|
|
2857
3180
|
},
|
|
2858
3181
|
handler: async (args2, ctx) => {
|
|
2859
3182
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2880,7 +3203,7 @@ defineTool({
|
|
|
2880
3203
|
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
2881
3204
|
].join("\n"),
|
|
2882
3205
|
input: {
|
|
2883
|
-
channel_id:
|
|
3206
|
+
channel_id: z15.number().int().positive().describe("Numeric channel id.")
|
|
2884
3207
|
},
|
|
2885
3208
|
handler: async (args2, ctx) => {
|
|
2886
3209
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2895,7 +3218,7 @@ defineTool({
|
|
|
2895
3218
|
});
|
|
2896
3219
|
|
|
2897
3220
|
// src/tools/projects.ts
|
|
2898
|
-
import { z as
|
|
3221
|
+
import { z as z16 } from "zod";
|
|
2899
3222
|
var AVAILABLE_REGION_IDS = ["eu-central-1"];
|
|
2900
3223
|
defineTool({
|
|
2901
3224
|
name: "list_projects",
|
|
@@ -2936,9 +3259,9 @@ defineTool({
|
|
|
2936
3259
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
2937
3260
|
].join("\n"),
|
|
2938
3261
|
input: {
|
|
2939
|
-
name:
|
|
2940
|
-
description:
|
|
2941
|
-
region:
|
|
3262
|
+
name: z16.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
3263
|
+
description: z16.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
3264
|
+
region: z16.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
|
|
2942
3265
|
},
|
|
2943
3266
|
handler: async (args2, ctx) => {
|
|
2944
3267
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2971,9 +3294,9 @@ defineTool({
|
|
|
2971
3294
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
2972
3295
|
].join("\n"),
|
|
2973
3296
|
input: {
|
|
2974
|
-
project_id:
|
|
2975
|
-
name:
|
|
2976
|
-
description:
|
|
3297
|
+
project_id: z16.string().describe("Project publicId."),
|
|
3298
|
+
name: z16.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
3299
|
+
description: z16.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
2977
3300
|
},
|
|
2978
3301
|
handler: async (args2, ctx) => {
|
|
2979
3302
|
if (args2.name === void 0 && args2.description === void 0) {
|
|
@@ -3007,7 +3330,7 @@ defineTool({
|
|
|
3007
3330
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
3008
3331
|
].join("\n"),
|
|
3009
3332
|
input: {
|
|
3010
|
-
project_id:
|
|
3333
|
+
project_id: z16.string().describe("Project publicId (e.g. prj_abc123).")
|
|
3011
3334
|
},
|
|
3012
3335
|
handler: async (args2, ctx) => {
|
|
3013
3336
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3018,8 +3341,156 @@ defineTool({
|
|
|
3018
3341
|
}
|
|
3019
3342
|
});
|
|
3020
3343
|
|
|
3344
|
+
// src/tools/dev-tasks.ts
|
|
3345
|
+
import { z as z17 } from "zod";
|
|
3346
|
+
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.";
|
|
3347
|
+
defineTool({
|
|
3348
|
+
name: "list_dev_tasks",
|
|
3349
|
+
category: "dev-tasks",
|
|
3350
|
+
description: [
|
|
3351
|
+
"List a project's agent task backlog - the same list the dashboard's Development \u2192 Tasks surface shows.",
|
|
3352
|
+
"",
|
|
3353
|
+
"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.",
|
|
3354
|
+
"",
|
|
3355
|
+
"Inputs:",
|
|
3356
|
+
' - project_id: numeric project id (from list_projects) or its "prj_\u2026" publicId.',
|
|
3357
|
+
"",
|
|
3358
|
+
"Returns: { summary, data: { items: DevTask[] } } - each task with publicId, title, status, serviceId (the box, or null for a loose idea), createdAt.",
|
|
3359
|
+
"",
|
|
3360
|
+
"`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.",
|
|
3361
|
+
"",
|
|
3362
|
+
'Example: list_dev_tasks({ project_id: 26 }) \u2192 { items: [{ publicId: "task_\u2026", status: "idea", title: "Fix the footprint join" }] }'
|
|
3363
|
+
].join("\n"),
|
|
3364
|
+
input: {
|
|
3365
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.')
|
|
3366
|
+
},
|
|
3367
|
+
handler: async (args2, ctx) => {
|
|
3368
|
+
const teamId = await ctx.resolveTeamId();
|
|
3369
|
+
const response = await ctx.hoststack.devTasks.list(teamId, args2.project_id);
|
|
3370
|
+
const data = shapeList(response, "tasks", shape);
|
|
3371
|
+
const open = data.items.filter(
|
|
3372
|
+
(t) => t && typeof t === "object" && "status" in t && !["done", "cancelled"].includes(String(t.status))
|
|
3373
|
+
).length;
|
|
3374
|
+
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."}`;
|
|
3375
|
+
return respond({ summary, data });
|
|
3376
|
+
}
|
|
3377
|
+
});
|
|
3378
|
+
defineTool({
|
|
3379
|
+
name: "get_dev_task",
|
|
3380
|
+
category: "dev-tasks",
|
|
3381
|
+
description: [
|
|
3382
|
+
"Get one task, including the full prompt body.",
|
|
3383
|
+
"",
|
|
3384
|
+
"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.",
|
|
3385
|
+
"",
|
|
3386
|
+
"Inputs:",
|
|
3387
|
+
' - task_id: "task_\u2026" publicId or numeric id.',
|
|
3388
|
+
"",
|
|
3389
|
+
"Returns: { summary, data: DevTask }.",
|
|
3390
|
+
"",
|
|
3391
|
+
'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" } }'
|
|
3392
|
+
].join("\n"),
|
|
3393
|
+
input: {
|
|
3394
|
+
task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.')
|
|
3395
|
+
},
|
|
3396
|
+
handler: async (args2, ctx) => {
|
|
3397
|
+
const teamId = await ctx.resolveTeamId();
|
|
3398
|
+
const response = await ctx.hoststack.devTasks.get(teamId, args2.task_id);
|
|
3399
|
+
const data = shape(response.task);
|
|
3400
|
+
return respond({ summary: `Task ${response.task.publicId}: ${response.task.title}`, data });
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
defineTool({
|
|
3404
|
+
name: "create_dev_task",
|
|
3405
|
+
category: "dev-tasks",
|
|
3406
|
+
description: [
|
|
3407
|
+
"File a task in a project's backlog, optionally pinned to a specific dev box.",
|
|
3408
|
+
"",
|
|
3409
|
+
`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}`,
|
|
3410
|
+
"",
|
|
3411
|
+
"Inputs:",
|
|
3412
|
+
' - project_id: numeric project id or "prj_\u2026" publicId.',
|
|
3413
|
+
" - title: one line, \u2264200 chars. This is what the backlog shows.",
|
|
3414
|
+
" - 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.",
|
|
3415
|
+
' - service_id: the dev box to pin it to ("svc_\u2026" or numeric). Omit for a loose idea in the project backlog.',
|
|
3416
|
+
"",
|
|
3417
|
+
"Returns: { summary, data: DevTask } - `publicId` is the id to quote back to the user.",
|
|
3418
|
+
"",
|
|
3419
|
+
'Example: create_dev_task({ project_id: 26, service_id: 51, title: "Footprint coverage", body: "The BBRUUID join returns 17 of 49 \u2026" })'
|
|
3420
|
+
].join("\n"),
|
|
3421
|
+
input: {
|
|
3422
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Project \u2014 publicId ("prj_\u2026") or numeric id.'),
|
|
3423
|
+
title: z17.string().min(1).max(200).describe("One-line title, \u2264200 chars."),
|
|
3424
|
+
body: z17.string().max(2e4).optional().describe("The prompt handed to the agent. Markdown, \u226420 000 chars."),
|
|
3425
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe(
|
|
3426
|
+
'Dev box to pin it to \u2014 publicId ("svc_\u2026") or numeric id. Omit for a loose idea.'
|
|
3427
|
+
)
|
|
3428
|
+
},
|
|
3429
|
+
handler: async (args2, ctx) => {
|
|
3430
|
+
const teamId = await ctx.resolveTeamId();
|
|
3431
|
+
const serviceId = args2.service_id === void 0 ? void 0 : await ctx.hoststack.resolveId(args2.service_id, { kind: "service", teamId });
|
|
3432
|
+
const projectId = await ctx.hoststack.resolveId(args2.project_id, {
|
|
3433
|
+
kind: "project",
|
|
3434
|
+
teamId
|
|
3435
|
+
});
|
|
3436
|
+
const response = await ctx.hoststack.devTasks.create(teamId, {
|
|
3437
|
+
projectId,
|
|
3438
|
+
title: args2.title,
|
|
3439
|
+
...args2.body ? { body: args2.body } : {},
|
|
3440
|
+
...serviceId ? { serviceId } : {}
|
|
3441
|
+
});
|
|
3442
|
+
const data = shape(response.task);
|
|
3443
|
+
return respond({
|
|
3444
|
+
summary: `Filed ${response.task.publicId}: ${response.task.title}. ${NOT_A_RUN}`,
|
|
3445
|
+
data
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
defineTool({
|
|
3450
|
+
name: "update_dev_task",
|
|
3451
|
+
category: "dev-tasks",
|
|
3452
|
+
description: [
|
|
3453
|
+
"Edit a task, or move it between the two statuses a PERSON may set.",
|
|
3454
|
+
"",
|
|
3455
|
+
"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.",
|
|
3456
|
+
"",
|
|
3457
|
+
"Inputs:",
|
|
3458
|
+
' - task_id: "task_\u2026" publicId or numeric id.',
|
|
3459
|
+
' - status: "done" or "idea". Only these two; `queued`/`running`/`failed`/`cancelled` belong to the runner and are rejected.',
|
|
3460
|
+
" - title / body / service_id: optional edits.",
|
|
3461
|
+
"",
|
|
3462
|
+
"Only mark a task done when it is actually resolved - the backlog is what someone reads to decide what still needs doing.",
|
|
3463
|
+
"",
|
|
3464
|
+
"Returns: { summary, data: DevTask }.",
|
|
3465
|
+
"",
|
|
3466
|
+
'Example: update_dev_task({ task_id: "task_hy1i2jdp\u2026", status: "done" }) \u2192 { data: { status: "done" } }'
|
|
3467
|
+
].join("\n"),
|
|
3468
|
+
input: {
|
|
3469
|
+
task_id: z17.union([z17.number().int().positive(), z17.string()]).describe('Task \u2014 publicId ("task_\u2026") or numeric id.'),
|
|
3470
|
+
status: z17.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
|
|
3471
|
+
title: z17.string().min(1).max(200).optional().describe("New title."),
|
|
3472
|
+
body: z17.string().max(2e4).optional().describe("New prompt body."),
|
|
3473
|
+
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.")
|
|
3474
|
+
},
|
|
3475
|
+
handler: async (args2, ctx) => {
|
|
3476
|
+
const teamId = await ctx.resolveTeamId();
|
|
3477
|
+
const serviceId = args2.service_id === void 0 || args2.service_id === null ? args2.service_id : await ctx.hoststack.resolveId(args2.service_id, { kind: "service", teamId });
|
|
3478
|
+
const response = await ctx.hoststack.devTasks.update(teamId, args2.task_id, {
|
|
3479
|
+
...args2.status ? { status: args2.status } : {},
|
|
3480
|
+
...args2.title ? { title: args2.title } : {},
|
|
3481
|
+
...args2.body !== void 0 ? { body: args2.body } : {},
|
|
3482
|
+
...serviceId !== void 0 ? { serviceId } : {}
|
|
3483
|
+
});
|
|
3484
|
+
const data = shape(response.task);
|
|
3485
|
+
return respond({
|
|
3486
|
+
summary: `${response.task.publicId} is now ${response.task.status}: ${response.task.title}`,
|
|
3487
|
+
data
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
});
|
|
3491
|
+
|
|
3021
3492
|
// src/tools/resource-links.ts
|
|
3022
|
-
import { z as
|
|
3493
|
+
import { z as z18 } from "zod";
|
|
3023
3494
|
var RESOURCE_LINK_TYPES = [
|
|
3024
3495
|
"database",
|
|
3025
3496
|
"object_storage",
|
|
@@ -3080,7 +3551,7 @@ defineTool({
|
|
|
3080
3551
|
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
3081
3552
|
].join("\n"),
|
|
3082
3553
|
input: {
|
|
3083
|
-
service_id:
|
|
3554
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
3084
3555
|
},
|
|
3085
3556
|
handler: async (args2, ctx) => {
|
|
3086
3557
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3117,10 +3588,10 @@ defineTool({
|
|
|
3117
3588
|
'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" } }'
|
|
3118
3589
|
].join("\n"),
|
|
3119
3590
|
input: {
|
|
3120
|
-
service_id:
|
|
3121
|
-
resource_type:
|
|
3122
|
-
resource_id:
|
|
3123
|
-
alias:
|
|
3591
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3592
|
+
resource_type: z18.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
3593
|
+
resource_id: z18.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
3594
|
+
alias: z18.string().min(1).max(48).regex(
|
|
3124
3595
|
/^[A-Z][A-Z0-9_]*$/,
|
|
3125
3596
|
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
3126
3597
|
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
@@ -3156,8 +3627,8 @@ defineTool({
|
|
|
3156
3627
|
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
3157
3628
|
].join("\n"),
|
|
3158
3629
|
input: {
|
|
3159
|
-
service_id:
|
|
3160
|
-
link_id:
|
|
3630
|
+
service_id: z18.union([z18.number().int().positive(), z18.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3631
|
+
link_id: z18.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
3161
3632
|
},
|
|
3162
3633
|
handler: async (args2, ctx) => {
|
|
3163
3634
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3169,7 +3640,7 @@ defineTool({
|
|
|
3169
3640
|
});
|
|
3170
3641
|
|
|
3171
3642
|
// src/tools/services.ts
|
|
3172
|
-
import { z as
|
|
3643
|
+
import { z as z19 } from "zod";
|
|
3173
3644
|
|
|
3174
3645
|
// src/lib/app-templates.ts
|
|
3175
3646
|
var MCP_APP_TEMPLATES = [
|
|
@@ -3426,11 +3897,11 @@ defineTool({
|
|
|
3426
3897
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
3427
3898
|
].join("\n"),
|
|
3428
3899
|
input: {
|
|
3429
|
-
project_id:
|
|
3430
|
-
environment_id:
|
|
3431
|
-
status:
|
|
3432
|
-
type:
|
|
3433
|
-
dev_environment:
|
|
3900
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
3901
|
+
environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
3902
|
+
status: z19.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
3903
|
+
type: z19.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
3904
|
+
dev_environment: z19.boolean().optional().describe(
|
|
3434
3905
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
3435
3906
|
)
|
|
3436
3907
|
},
|
|
@@ -3503,29 +3974,29 @@ defineTool({
|
|
|
3503
3974
|
'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.'
|
|
3504
3975
|
].join("\n"),
|
|
3505
3976
|
input: {
|
|
3506
|
-
project_id:
|
|
3507
|
-
name:
|
|
3508
|
-
type:
|
|
3509
|
-
docker_image:
|
|
3977
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3978
|
+
name: z19.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
3979
|
+
type: z19.enum(SERVICE_TYPES).describe("Service type."),
|
|
3980
|
+
docker_image: z19.string().max(500).optional().describe(
|
|
3510
3981
|
"Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
|
|
3511
3982
|
),
|
|
3512
|
-
github_repo_id:
|
|
3513
|
-
branch:
|
|
3514
|
-
install_command:
|
|
3515
|
-
build_command:
|
|
3516
|
-
start_command:
|
|
3517
|
-
cron_schedule:
|
|
3518
|
-
publish_path:
|
|
3519
|
-
runtime:
|
|
3520
|
-
port:
|
|
3983
|
+
github_repo_id: z19.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
3984
|
+
branch: z19.string().max(200).optional().describe('Git branch (default "main").'),
|
|
3985
|
+
install_command: z19.string().max(1e3).optional().describe("Install shell command."),
|
|
3986
|
+
build_command: z19.string().max(1e3).optional().describe("Build shell command."),
|
|
3987
|
+
start_command: z19.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
3988
|
+
cron_schedule: z19.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
3989
|
+
publish_path: z19.string().max(500).optional().describe("Static-site output dir."),
|
|
3990
|
+
runtime: z19.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
3991
|
+
port: z19.number().int().min(1).max(65535).optional().describe(
|
|
3521
3992
|
"Listen port, for a prebuilt image whose port is fixed by the image. Omit for a source-built service \u2014 it binds the injected $PORT."
|
|
3522
3993
|
),
|
|
3523
|
-
template_id:
|
|
3994
|
+
template_id: z19.string().max(64).optional().describe(
|
|
3524
3995
|
"Quickstart template id from list_templates. Its volumes, scratch dirs, uid, secrets and companion database are resolved server-side; for an image template also pass its docker_image and port."
|
|
3525
3996
|
),
|
|
3526
|
-
plan:
|
|
3527
|
-
environment_id:
|
|
3528
|
-
auto_deploy:
|
|
3997
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
3998
|
+
environment_id: z19.union([z19.number().int().positive(), z19.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
3999
|
+
auto_deploy: z19.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
|
|
3529
4000
|
machine: machineInput
|
|
3530
4001
|
},
|
|
3531
4002
|
handler: async (args2, ctx) => {
|
|
@@ -3596,18 +4067,18 @@ defineTool({
|
|
|
3596
4067
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
3597
4068
|
].join("\n"),
|
|
3598
4069
|
input: {
|
|
3599
|
-
project_id:
|
|
3600
|
-
name:
|
|
3601
|
-
plan:
|
|
4070
|
+
project_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
4071
|
+
name: z19.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
4072
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe(
|
|
3602
4073
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
3603
4074
|
),
|
|
3604
|
-
disk_gb:
|
|
3605
|
-
hoststack_api_key:
|
|
3606
|
-
poststack_api_key:
|
|
3607
|
-
repo_url:
|
|
4075
|
+
disk_gb: z19.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
|
|
4076
|
+
hoststack_api_key: z19.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
4077
|
+
poststack_api_key: z19.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
4078
|
+
repo_url: z19.string().max(500).optional().describe(
|
|
3608
4079
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
3609
4080
|
),
|
|
3610
|
-
branch:
|
|
4081
|
+
branch: z19.string().max(200).optional().describe("Branch to clone (with repo_url)."),
|
|
3611
4082
|
machine: machineInput
|
|
3612
4083
|
},
|
|
3613
4084
|
handler: async (args2, ctx) => {
|
|
@@ -3724,9 +4195,9 @@ defineTool({
|
|
|
3724
4195
|
'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.'
|
|
3725
4196
|
].join("\n"),
|
|
3726
4197
|
input: {
|
|
3727
|
-
service_id:
|
|
3728
|
-
include_database_clone:
|
|
3729
|
-
name:
|
|
4198
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
4199
|
+
include_database_clone: z19.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
4200
|
+
name: z19.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
3730
4201
|
},
|
|
3731
4202
|
handler: async (args2, ctx) => {
|
|
3732
4203
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3767,7 +4238,7 @@ defineTool({
|
|
|
3767
4238
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
3768
4239
|
].join("\n"),
|
|
3769
4240
|
input: {
|
|
3770
|
-
service_id:
|
|
4241
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
3771
4242
|
},
|
|
3772
4243
|
handler: async (args2, ctx) => {
|
|
3773
4244
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3790,10 +4261,12 @@ defineTool({
|
|
|
3790
4261
|
"",
|
|
3791
4262
|
"This resizes a cloud Dev Box, NOT a project deploy environment.",
|
|
3792
4263
|
"",
|
|
3793
|
-
"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
|
|
4264
|
+
"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.",
|
|
3794
4265
|
"",
|
|
3795
4266
|
"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.",
|
|
3796
4267
|
"",
|
|
4268
|
+
"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.",
|
|
4269
|
+
"",
|
|
3797
4270
|
"Inputs:",
|
|
3798
4271
|
" - service_id: the box to resize \u2014 numeric id or publicId.",
|
|
3799
4272
|
' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
|
|
@@ -3803,8 +4276,8 @@ defineTool({
|
|
|
3803
4276
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
3804
4277
|
].join("\n"),
|
|
3805
4278
|
input: {
|
|
3806
|
-
service_id:
|
|
3807
|
-
size:
|
|
4279
|
+
service_id: z19.union([z19.number().int().positive(), z19.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
4280
|
+
size: z19.enum(SERVICE_PLANS).describe(
|
|
3808
4281
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
3809
4282
|
)
|
|
3810
4283
|
},
|
|
@@ -3831,7 +4304,9 @@ defineTool({
|
|
|
3831
4304
|
"",
|
|
3832
4305
|
`When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
|
|
3833
4306
|
"",
|
|
3834
|
-
'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).',
|
|
4307
|
+
'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).',
|
|
4308
|
+
"",
|
|
4309
|
+
'`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.',
|
|
3835
4310
|
"",
|
|
3836
4311
|
"Example: list_dev_environments() \u2192 every dev box for the active team."
|
|
3837
4312
|
].join("\n"),
|
|
@@ -3848,7 +4323,12 @@ defineTool({
|
|
|
3848
4323
|
devUrl: env.devUrl ?? null,
|
|
3849
4324
|
databases: env.databases ?? [],
|
|
3850
4325
|
exitReason: env.exitReason ?? null,
|
|
3851
|
-
recommendedSize: env.recommendedSize ?? null
|
|
4326
|
+
recommendedSize: env.recommendedSize ?? null,
|
|
4327
|
+
// The number an agent should size its work against. Absent it,
|
|
4328
|
+
// the only honest way to learn a box's ceiling was to shell in
|
|
4329
|
+
// and read the cgroup — so every box rediscovered its own.
|
|
4330
|
+
effectiveMemoryMb: env.effectiveMemoryMb ?? null,
|
|
4331
|
+
memoryPinnedBelowTier: env.memoryPinnedBelowTier ?? false
|
|
3852
4332
|
}))
|
|
3853
4333
|
}
|
|
3854
4334
|
});
|
|
@@ -3947,21 +4427,21 @@ defineTool({
|
|
|
3947
4427
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
3948
4428
|
].join("\n"),
|
|
3949
4429
|
input: {
|
|
3950
|
-
name:
|
|
4430
|
+
name: z19.string().min(1).max(100).optional().describe(
|
|
3951
4431
|
'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.'
|
|
3952
4432
|
),
|
|
3953
|
-
source_kind:
|
|
3954
|
-
github_repo_id:
|
|
3955
|
-
clone_url:
|
|
3956
|
-
branch:
|
|
3957
|
-
databases:
|
|
3958
|
-
plan:
|
|
4433
|
+
source_kind: z19.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
4434
|
+
github_repo_id: z19.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
4435
|
+
clone_url: z19.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
4436
|
+
branch: z19.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
4437
|
+
databases: z19.array(z19.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
4438
|
+
plan: z19.enum(SERVICE_PLANS).optional().describe(
|
|
3959
4439
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
3960
4440
|
),
|
|
3961
|
-
agent_accounts:
|
|
3962
|
-
|
|
3963
|
-
provider:
|
|
3964
|
-
account_id:
|
|
4441
|
+
agent_accounts: z19.array(
|
|
4442
|
+
z19.object({
|
|
4443
|
+
provider: z19.enum(["claude", "codex", "opencode"]),
|
|
4444
|
+
account_id: z19.number().int().positive()
|
|
3965
4445
|
})
|
|
3966
4446
|
).max(3).optional().describe(
|
|
3967
4447
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
@@ -4034,12 +4514,12 @@ defineTool({
|
|
|
4034
4514
|
"Inputs:",
|
|
4035
4515
|
' - service_id: publicId of the service (e.g. "svc_abc123").',
|
|
4036
4516
|
"",
|
|
4037
|
-
'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.',
|
|
4517
|
+
'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.',
|
|
4038
4518
|
"",
|
|
4039
4519
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
4040
4520
|
].join("\n"),
|
|
4041
4521
|
input: {
|
|
4042
|
-
service_id:
|
|
4522
|
+
service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4043
4523
|
},
|
|
4044
4524
|
handler: async (args2, ctx) => {
|
|
4045
4525
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4071,7 +4551,7 @@ defineTool({
|
|
|
4071
4551
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
4072
4552
|
].join("\n"),
|
|
4073
4553
|
input: {
|
|
4074
|
-
service_id:
|
|
4554
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4075
4555
|
},
|
|
4076
4556
|
handler: async (args2, ctx) => {
|
|
4077
4557
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4093,16 +4573,20 @@ defineTool({
|
|
|
4093
4573
|
' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
|
|
4094
4574
|
" - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
|
|
4095
4575
|
"",
|
|
4096
|
-
"Resolution: \u22647d \u2192 raw samples (~
|
|
4576
|
+
"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.",
|
|
4577
|
+
"",
|
|
4578
|
+
"`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).",
|
|
4579
|
+
"",
|
|
4580
|
+
"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.",
|
|
4097
4581
|
"",
|
|
4098
4582
|
"Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
|
|
4099
4583
|
"",
|
|
4100
|
-
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192
|
|
4584
|
+
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 ~120 points for the last hour.'
|
|
4101
4585
|
].join("\n"),
|
|
4102
4586
|
input: {
|
|
4103
|
-
service_id:
|
|
4104
|
-
from:
|
|
4105
|
-
to:
|
|
4587
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4588
|
+
from: z19.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
4589
|
+
to: z19.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
4106
4590
|
},
|
|
4107
4591
|
handler: async (args2, ctx) => {
|
|
4108
4592
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4146,8 +4630,8 @@ defineTool({
|
|
|
4146
4630
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
4147
4631
|
].join("\n"),
|
|
4148
4632
|
input: {
|
|
4149
|
-
service_id:
|
|
4150
|
-
name:
|
|
4633
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4634
|
+
name: z19.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
4151
4635
|
},
|
|
4152
4636
|
handler: async (args2, ctx) => {
|
|
4153
4637
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4175,6 +4659,7 @@ defineTool({
|
|
|
4175
4659
|
" - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
|
|
4176
4660
|
' - health_check_path (optional): HTTP path the platform GETs to verify liveness (e.g. "/health"). Pass null for TCP-only check.',
|
|
4177
4661
|
" - health_check_enabled (optional): boolean \u2014 toggle health checking on/off.",
|
|
4662
|
+
" - 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.",
|
|
4178
4663
|
" - health_check_interval (optional): integer 5\u2013300 seconds \u2014 how often the check runs.",
|
|
4179
4664
|
" - health_check_timeout (optional): integer 1\u201360 seconds \u2014 single-attempt timeout.",
|
|
4180
4665
|
' - 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).',
|
|
@@ -4189,47 +4674,50 @@ defineTool({
|
|
|
4189
4674
|
" - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
|
|
4190
4675
|
" - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
|
|
4191
4676
|
" - scale_cpu_threshold, scale_memory_threshold (optional): integer 10\u2013100 \u2014 autoscale trigger percentage.",
|
|
4192
|
-
|
|
4677
|
+
` - 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.`,
|
|
4193
4678
|
"",
|
|
4194
4679
|
"Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
|
|
4195
4680
|
"",
|
|
4196
4681
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
4197
4682
|
].join("\n"),
|
|
4198
4683
|
input: {
|
|
4199
|
-
service_id:
|
|
4200
|
-
install_command:
|
|
4201
|
-
build_command:
|
|
4202
|
-
start_command:
|
|
4203
|
-
branch:
|
|
4204
|
-
root_directory:
|
|
4205
|
-
dockerfile_path:
|
|
4206
|
-
auto_deploy:
|
|
4207
|
-
health_check_path:
|
|
4208
|
-
health_check_enabled:
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4684
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4685
|
+
install_command: z19.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
4686
|
+
build_command: z19.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
4687
|
+
start_command: z19.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
4688
|
+
branch: z19.string().optional().describe("Git branch to track."),
|
|
4689
|
+
root_directory: z19.string().optional().describe("Build context root."),
|
|
4690
|
+
dockerfile_path: z19.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
4691
|
+
auto_deploy: z19.boolean().optional().describe("Auto-deploy on push."),
|
|
4692
|
+
health_check_path: z19.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
4693
|
+
health_check_enabled: z19.boolean().optional().describe("Toggle health checking on/off."),
|
|
4694
|
+
allow_search_indexing: z19.boolean().optional().describe(
|
|
4695
|
+
"Let search engines index the free *.hoststack.dev platform URL (off by default). Custom domains are always indexable. Applies on the next deploy."
|
|
4696
|
+
),
|
|
4697
|
+
health_check_interval: z19.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
4698
|
+
health_check_timeout: z19.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
4699
|
+
health_check_grace_period_sec: z19.number().int().min(1).max(1800).optional().describe(
|
|
4212
4700
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
4213
4701
|
),
|
|
4214
|
-
memory_mb:
|
|
4215
|
-
cpu_shares:
|
|
4216
|
-
disk_size_gb:
|
|
4217
|
-
port:
|
|
4218
|
-
protocol:
|
|
4219
|
-
restart_policy:
|
|
4220
|
-
deploy_strategy:
|
|
4702
|
+
memory_mb: z19.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
4703
|
+
cpu_shares: z19.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
4704
|
+
disk_size_gb: z19.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
4705
|
+
port: z19.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
4706
|
+
protocol: z19.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
4707
|
+
restart_policy: z19.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
4708
|
+
deploy_strategy: z19.enum(["rolling", "recreate"]).optional().describe(
|
|
4221
4709
|
'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.'
|
|
4222
4710
|
),
|
|
4223
|
-
pre_deploy_command:
|
|
4224
|
-
instance_count:
|
|
4225
|
-
min_instances:
|
|
4226
|
-
max_instances:
|
|
4227
|
-
scale_cpu_threshold:
|
|
4228
|
-
scale_memory_threshold:
|
|
4229
|
-
log_filter_rules:
|
|
4230
|
-
|
|
4231
|
-
pattern:
|
|
4232
|
-
action:
|
|
4711
|
+
pre_deploy_command: z19.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
4712
|
+
instance_count: z19.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
4713
|
+
min_instances: z19.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
4714
|
+
max_instances: z19.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
4715
|
+
scale_cpu_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
4716
|
+
scale_memory_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
4717
|
+
log_filter_rules: z19.array(
|
|
4718
|
+
z19.object({
|
|
4719
|
+
pattern: z19.string().min(1).max(200),
|
|
4720
|
+
action: z19.enum(["drop", "downgrade"])
|
|
4233
4721
|
})
|
|
4234
4722
|
).max(50).optional().describe(
|
|
4235
4723
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -4252,6 +4740,8 @@ defineTool({
|
|
|
4252
4740
|
const configUpdate = {};
|
|
4253
4741
|
if (args2.health_check_enabled !== void 0)
|
|
4254
4742
|
configUpdate["healthCheckEnabled"] = args2.health_check_enabled;
|
|
4743
|
+
if (args2.allow_search_indexing !== void 0)
|
|
4744
|
+
configUpdate["allowSearchIndexing"] = args2.allow_search_indexing;
|
|
4255
4745
|
if (args2.health_check_interval !== void 0)
|
|
4256
4746
|
configUpdate["healthCheckInterval"] = args2.health_check_interval;
|
|
4257
4747
|
if (args2.health_check_timeout !== void 0)
|
|
@@ -4326,7 +4816,7 @@ defineTool({
|
|
|
4326
4816
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
4327
4817
|
].join("\n"),
|
|
4328
4818
|
input: {
|
|
4329
|
-
service_id:
|
|
4819
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4330
4820
|
},
|
|
4331
4821
|
handler: async (args2, ctx) => {
|
|
4332
4822
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4350,7 +4840,7 @@ defineTool({
|
|
|
4350
4840
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
4351
4841
|
].join("\n"),
|
|
4352
4842
|
input: {
|
|
4353
|
-
service_id:
|
|
4843
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4354
4844
|
},
|
|
4355
4845
|
handler: async (args2, ctx) => {
|
|
4356
4846
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4376,7 +4866,7 @@ defineTool({
|
|
|
4376
4866
|
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
4377
4867
|
].join("\n"),
|
|
4378
4868
|
input: {
|
|
4379
|
-
service_id:
|
|
4869
|
+
service_id: z19.string().describe("Service publicId.")
|
|
4380
4870
|
},
|
|
4381
4871
|
handler: async (args2, ctx) => {
|
|
4382
4872
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4402,7 +4892,9 @@ defineTool({
|
|
|
4402
4892
|
' - stream (optional): "stdout" | "stderr". Omit to combine.',
|
|
4403
4893
|
" - 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).",
|
|
4404
4894
|
" - search (optional): case-insensitive substring grep, \u2264100 chars.",
|
|
4405
|
-
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
|
|
4895
|
+
' - 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.',
|
|
4896
|
+
"",
|
|
4897
|
+
"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.",
|
|
4406
4898
|
"",
|
|
4407
4899
|
"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.",
|
|
4408
4900
|
"",
|
|
@@ -4413,16 +4905,16 @@ defineTool({
|
|
|
4413
4905
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
4414
4906
|
].join("\n"),
|
|
4415
4907
|
input: {
|
|
4416
|
-
service_id:
|
|
4417
|
-
lines:
|
|
4418
|
-
since:
|
|
4419
|
-
until:
|
|
4420
|
-
stream:
|
|
4421
|
-
level:
|
|
4908
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4909
|
+
lines: z19.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
4910
|
+
since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4911
|
+
until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4912
|
+
stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4913
|
+
level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
4422
4914
|
"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)."
|
|
4423
4915
|
),
|
|
4424
|
-
search:
|
|
4425
|
-
count_only:
|
|
4916
|
+
search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4917
|
+
count_only: z19.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
4426
4918
|
},
|
|
4427
4919
|
handler: async (args2, ctx) => {
|
|
4428
4920
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4471,14 +4963,14 @@ defineTool({
|
|
|
4471
4963
|
'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 } } }.'
|
|
4472
4964
|
].join("\n"),
|
|
4473
4965
|
input: {
|
|
4474
|
-
service_ids:
|
|
4475
|
-
lines_per_service:
|
|
4476
|
-
since:
|
|
4477
|
-
until:
|
|
4478
|
-
stream:
|
|
4479
|
-
level:
|
|
4480
|
-
search:
|
|
4481
|
-
count_only:
|
|
4966
|
+
service_ids: z19.array(z19.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
4967
|
+
lines_per_service: z19.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
4968
|
+
since: z19.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4969
|
+
until: z19.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4970
|
+
stream: z19.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4971
|
+
level: z19.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
4972
|
+
search: z19.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4973
|
+
count_only: z19.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
4482
4974
|
},
|
|
4483
4975
|
handler: async (args2, ctx) => {
|
|
4484
4976
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4524,7 +5016,7 @@ defineTool({
|
|
|
4524
5016
|
});
|
|
4525
5017
|
|
|
4526
5018
|
// src/tools/uptime.ts
|
|
4527
|
-
import { z as
|
|
5019
|
+
import { z as z20 } from "zod";
|
|
4528
5020
|
var STATUS_MEANING = [
|
|
4529
5021
|
"Status vocabulary: `up` answering as expected \xB7 `down` failed the threshold and an alert is open \xB7 `unknown` not probed yet \xB7 `unresolvable` the service has no active domain to request \xB7 `paused` the service is not meant to be answering (suspended, mid-deploy, never deployed) OR it sleeps when idle and probing it would keep it awake."
|
|
4530
5022
|
].join("\n");
|
|
@@ -4540,11 +5032,13 @@ defineTool({
|
|
|
4540
5032
|
"",
|
|
4541
5033
|
"Inputs: serviceId (required).",
|
|
4542
5034
|
"",
|
|
4543
|
-
'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
|
|
5035
|
+
'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.',
|
|
5036
|
+
"",
|
|
5037
|
+
"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.",
|
|
4544
5038
|
"",
|
|
4545
|
-
"Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
|
|
5039
|
+
"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' } }."
|
|
4546
5040
|
].join("\n"),
|
|
4547
|
-
input: { serviceId:
|
|
5041
|
+
input: { serviceId: z20.number().int().positive() },
|
|
4548
5042
|
handler: async (args2, ctx) => {
|
|
4549
5043
|
const teamId = await ctx.resolveTeamId();
|
|
4550
5044
|
const response = await ctx.api.get(
|
|
@@ -4573,9 +5067,11 @@ defineTool({
|
|
|
4573
5067
|
"",
|
|
4574
5068
|
'Only service types with a public URL can be checked (web services and static sites). A worker, cron job or private service is refused with 400 \u2014 a check on one could only ever report "no domain to check", which reads as a broken feature rather than an inapplicable one.',
|
|
4575
5069
|
"",
|
|
4576
|
-
"
|
|
5070
|
+
"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.",
|
|
4577
5071
|
"",
|
|
4578
|
-
"
|
|
5072
|
+
"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.",
|
|
5073
|
+
"",
|
|
5074
|
+
"`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.",
|
|
4579
5075
|
"",
|
|
4580
5076
|
"Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
|
|
4581
5077
|
"",
|
|
@@ -4588,14 +5084,14 @@ defineTool({
|
|
|
4588
5084
|
"Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
4589
5085
|
].join("\n"),
|
|
4590
5086
|
input: {
|
|
4591
|
-
serviceId:
|
|
4592
|
-
enabled:
|
|
4593
|
-
path:
|
|
4594
|
-
method:
|
|
4595
|
-
expectedStatus:
|
|
4596
|
-
timeoutMs:
|
|
4597
|
-
intervalSeconds:
|
|
4598
|
-
failureThreshold:
|
|
5087
|
+
serviceId: z20.number().int().positive(),
|
|
5088
|
+
enabled: z20.boolean().optional(),
|
|
5089
|
+
path: z20.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
5090
|
+
method: z20.enum(["GET", "HEAD"]).optional(),
|
|
5091
|
+
expectedStatus: z20.number().int().min(100).max(599).optional(),
|
|
5092
|
+
timeoutMs: z20.number().int().min(1e3).max(6e4).optional(),
|
|
5093
|
+
intervalSeconds: z20.number().int().min(30).max(3600).optional(),
|
|
5094
|
+
failureThreshold: z20.number().int().min(1).max(10).optional()
|
|
4599
5095
|
},
|
|
4600
5096
|
handler: async (args2, ctx) => {
|
|
4601
5097
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4604,9 +5100,11 @@ defineTool({
|
|
|
4604
5100
|
`/api/services/${teamId}/${serviceId}/uptime-check`,
|
|
4605
5101
|
body
|
|
4606
5102
|
);
|
|
5103
|
+
const check = shape(response.check);
|
|
5104
|
+
const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
|
|
4607
5105
|
return respond({
|
|
4608
|
-
summary: `Uptime check saved. It
|
|
4609
|
-
data: { check
|
|
5106
|
+
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.',
|
|
5107
|
+
data: { check }
|
|
4610
5108
|
});
|
|
4611
5109
|
}
|
|
4612
5110
|
});
|
|
@@ -4624,7 +5122,7 @@ defineTool({
|
|
|
4624
5122
|
"",
|
|
4625
5123
|
"Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
|
|
4626
5124
|
].join("\n"),
|
|
4627
|
-
input: { serviceId:
|
|
5125
|
+
input: { serviceId: z20.number().int().positive() },
|
|
4628
5126
|
handler: async (args2, ctx) => {
|
|
4629
5127
|
const teamId = await ctx.resolveTeamId();
|
|
4630
5128
|
await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/uptime-check`);
|
|
@@ -4633,7 +5131,7 @@ defineTool({
|
|
|
4633
5131
|
});
|
|
4634
5132
|
|
|
4635
5133
|
// src/tools/volumes.ts
|
|
4636
|
-
import { z as
|
|
5134
|
+
import { z as z21 } from "zod";
|
|
4637
5135
|
var MIN_VOLUME_SIZE_GB = 10;
|
|
4638
5136
|
defineTool({
|
|
4639
5137
|
name: "list_volumes",
|
|
@@ -4651,7 +5149,7 @@ defineTool({
|
|
|
4651
5149
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
4652
5150
|
].join("\n"),
|
|
4653
5151
|
input: {
|
|
4654
|
-
service_id:
|
|
5152
|
+
service_id: z21.string().describe("Service publicId (e.g. svc_abc123).")
|
|
4655
5153
|
},
|
|
4656
5154
|
handler: async (args2, ctx) => {
|
|
4657
5155
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4680,15 +5178,15 @@ defineTool({
|
|
|
4680
5178
|
'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" } }'
|
|
4681
5179
|
].join("\n"),
|
|
4682
5180
|
input: {
|
|
4683
|
-
service_id:
|
|
4684
|
-
name:
|
|
4685
|
-
mount_path:
|
|
5181
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5182
|
+
name: z21.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
5183
|
+
mount_path: z21.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
4686
5184
|
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
4687
5185
|
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
4688
5186
|
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
4689
5187
|
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
4690
5188
|
// size. Reject it at the call instead.
|
|
4691
|
-
size_gb:
|
|
5189
|
+
size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
4692
5190
|
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
4693
5191
|
)
|
|
4694
5192
|
},
|
|
@@ -4726,10 +5224,10 @@ defineTool({
|
|
|
4726
5224
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
4727
5225
|
].join("\n"),
|
|
4728
5226
|
input: {
|
|
4729
|
-
service_id:
|
|
4730
|
-
volume_id:
|
|
4731
|
-
mount_path:
|
|
4732
|
-
size_gb:
|
|
5227
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5228
|
+
volume_id: z21.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
5229
|
+
mount_path: z21.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
5230
|
+
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).`)
|
|
4733
5231
|
},
|
|
4734
5232
|
handler: async (args2, ctx) => {
|
|
4735
5233
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -4767,8 +5265,8 @@ defineTool({
|
|
|
4767
5265
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
4768
5266
|
].join("\n"),
|
|
4769
5267
|
input: {
|
|
4770
|
-
service_id:
|
|
4771
|
-
volume_id:
|
|
5268
|
+
service_id: z21.string().describe("Service publicId."),
|
|
5269
|
+
volume_id: z21.string().describe("Volume publicId.")
|
|
4772
5270
|
},
|
|
4773
5271
|
handler: async (args2, ctx) => {
|
|
4774
5272
|
const teamId = await ctx.resolveTeamId();
|