@nullplatform/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +252 -0
- package/dist/config.js +26 -0
- package/dist/git.js +27 -0
- package/dist/http.js +330 -0
- package/dist/i18n.js +595 -0
- package/dist/index.js +72 -0
- package/dist/md.js +110 -0
- package/dist/np/auth.js +130 -0
- package/dist/np/client.js +72 -0
- package/dist/np/context.js +201 -0
- package/dist/np/journey.js +403 -0
- package/dist/prompts.js +64 -0
- package/dist/render.js +236 -0
- package/dist/server.js +91 -0
- package/dist/skills.js +84 -0
- package/dist/surfaces/developer.js +29 -0
- package/dist/surfaces/index.js +17 -0
- package/dist/surfaces/surface.js +1 -0
- package/dist/tool-names.js +25 -0
- package/dist/tool.js +92 -0
- package/dist/tools/approvals.js +80 -0
- package/dist/tools/builds.js +94 -0
- package/dist/tools/create-app.js +187 -0
- package/dist/tools/create-release.js +52 -0
- package/dist/tools/create-scope.js +82 -0
- package/dist/tools/deploy.js +178 -0
- package/dist/tools/find-apps.js +36 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/logs.js +83 -0
- package/dist/tools/metrics.js +83 -0
- package/dist/tools/overview.js +110 -0
- package/dist/tools/params.js +58 -0
- package/dist/tools/playbook.js +39 -0
- package/dist/tools/services.js +58 -0
- package/dist/tools/set-params.js +58 -0
- package/dist/tools/shared.js +141 -0
- package/dist/tools/status.js +70 -0
- package/dist/tools/traffic.js +74 -0
- package/dist/ui.js +76 -0
- package/package.json +65 -0
- package/skills/deploying-safely/SKILL.md +54 -0
- package/skills/incident-response/SKILL.md +52 -0
- package/skills/platform-conventions/SKILL.md +61 -0
- package/widgets-dist/create-app.html +830 -0
- package/widgets-dist/find-apps.html +831 -0
- package/widgets-dist/logs.html +830 -0
- package/widgets-dist/manifest.json +8 -0
- package/widgets-dist/metrics.html +829 -0
- package/widgets-dist/np-panel.html +831 -0
- package/widgets-dist/params.html +829 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { approvalsTool } from "./approvals.js";
|
|
2
|
+
import { buildsTool } from "./builds.js";
|
|
3
|
+
import { createAppTool } from "./create-app.js";
|
|
4
|
+
import { createReleaseTool } from "./create-release.js";
|
|
5
|
+
import { createScopeTool } from "./create-scope.js";
|
|
6
|
+
import { deployTool } from "./deploy.js";
|
|
7
|
+
import { findAppsTool } from "./find-apps.js";
|
|
8
|
+
import { logsTool } from "./logs.js";
|
|
9
|
+
import { metricsTool } from "./metrics.js";
|
|
10
|
+
import { overviewTool } from "./overview.js";
|
|
11
|
+
import { paramsTool } from "./params.js";
|
|
12
|
+
import { playbookGetTool } from "./playbook.js";
|
|
13
|
+
import { servicesTool } from "./services.js";
|
|
14
|
+
import { setParamsTool } from "./set-params.js";
|
|
15
|
+
import { statusTool } from "./status.js";
|
|
16
|
+
import { trafficTool } from "./traffic.js";
|
|
17
|
+
/**
|
|
18
|
+
* The tool registry — the single extension point. To add a tool: create
|
|
19
|
+
* src/tools/<name>.ts exporting a defineTool({...}) and list it here. Its widget
|
|
20
|
+
* (if declared) is registered automatically by the server assembly.
|
|
21
|
+
*/
|
|
22
|
+
export const tools = [
|
|
23
|
+
statusTool,
|
|
24
|
+
overviewTool,
|
|
25
|
+
findAppsTool,
|
|
26
|
+
buildsTool,
|
|
27
|
+
logsTool,
|
|
28
|
+
paramsTool,
|
|
29
|
+
metricsTool,
|
|
30
|
+
deployTool,
|
|
31
|
+
trafficTool,
|
|
32
|
+
createReleaseTool,
|
|
33
|
+
setParamsTool,
|
|
34
|
+
createAppTool,
|
|
35
|
+
createScopeTool,
|
|
36
|
+
approvalsTool,
|
|
37
|
+
servicesTool,
|
|
38
|
+
playbookGetTool,
|
|
39
|
+
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, linkLine, next } from "../md.js";
|
|
4
|
+
import { listScopes, readLogs } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, chooseScope, requireApp } from "./shared.js";
|
|
8
|
+
export const logsTool = defineTool({
|
|
9
|
+
name: TOOL.applicationLogList,
|
|
10
|
+
title: "Read logs",
|
|
11
|
+
description: "Read recent application logs (optionally for one scope). Returns the latest lines, newest last — good for a quick 'why is it failing?' look.",
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
13
|
+
widget: "logs",
|
|
14
|
+
errorKey: "logs.errorLabel",
|
|
15
|
+
onError: (message) => fail(`${translate("logs.errorLabel")}: ${message}\n${translate("logs.errorSuffix")}`),
|
|
16
|
+
inputSchema: {
|
|
17
|
+
app: appArg,
|
|
18
|
+
scope: z.string().optional().describe("Scope name (defaults to the only scope, or all)"),
|
|
19
|
+
lines: z.number().optional().describe("Max lines (default 50)"),
|
|
20
|
+
start_time: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("ISO-8601 start of the time window, e.g. 2026-06-13T10:00:00Z"),
|
|
24
|
+
end_time: z.string().optional().describe("ISO-8601 end of the time window (defaults to now)"),
|
|
25
|
+
},
|
|
26
|
+
async handler(args, context) {
|
|
27
|
+
const resolved = await requireApp(context, args);
|
|
28
|
+
if ("out" in resolved)
|
|
29
|
+
return resolved.out;
|
|
30
|
+
const app = resolved.app;
|
|
31
|
+
// Logs live per scope on the platform — resolve one before reading.
|
|
32
|
+
const scopes = await listScopes(context.np, app.id);
|
|
33
|
+
if (scopes.length === 0) {
|
|
34
|
+
return reply(translate("logs.noScopes", { app: app.name }) + next(translate("logs.noScopesHint")));
|
|
35
|
+
}
|
|
36
|
+
const picked = chooseScope(scopes, args.scope, {
|
|
37
|
+
nounKey: "logs.noun",
|
|
38
|
+
tool: TOOL.applicationLogList,
|
|
39
|
+
app,
|
|
40
|
+
});
|
|
41
|
+
if ("out" in picked)
|
|
42
|
+
return picked.out;
|
|
43
|
+
const scope = picked.scope;
|
|
44
|
+
const maxLines = args.lines ?? 50;
|
|
45
|
+
const page = await readLogs(context.np, {
|
|
46
|
+
application_id: app.id,
|
|
47
|
+
scope_id: scope.id,
|
|
48
|
+
start_time: args.start_time,
|
|
49
|
+
end_time: args.end_time,
|
|
50
|
+
});
|
|
51
|
+
const entries = (page.results ?? []).slice(-maxLines);
|
|
52
|
+
const scopeSuffix = ` · ${scope.name}`;
|
|
53
|
+
// The full scope list rides along so the widget can offer a scope switcher.
|
|
54
|
+
const scopeList = scopes.map((candidate) => ({ name: candidate.name, status: candidate.status }));
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
const orgSlug = await context.org.organizationSlug();
|
|
57
|
+
return reply(translate("logs.empty", { app: app.name, scope: scopeSuffix }) +
|
|
58
|
+
linkLine(translate("logs.openInDashboard"), dashboardLink(orgSlug, app.nrn)), { count: 0, app: `#${app.id}`, app_name: app.name, scope: scope.name, scopes: scopeList, lines: [] });
|
|
59
|
+
}
|
|
60
|
+
const body = entries
|
|
61
|
+
.map((entry) => {
|
|
62
|
+
const timestamp = entry.datetime ?? entry.timestamp;
|
|
63
|
+
const prefix = timestamp
|
|
64
|
+
? `${new Date(timestamp).toISOString().slice(5, 19).replace("T", " ")} `
|
|
65
|
+
: "";
|
|
66
|
+
return `${prefix}${(entry.message ?? "").trimEnd()}`;
|
|
67
|
+
})
|
|
68
|
+
.join("\n");
|
|
69
|
+
const markdown = `${translate("logs.lastLines", { app: app.name, scope: scopeSuffix, count: entries.length })}\n\n\`\`\`\n${body}\n\`\`\``;
|
|
70
|
+
return reply(markdown, {
|
|
71
|
+
count: entries.length,
|
|
72
|
+
next_page_token: page.next_page_token ?? null,
|
|
73
|
+
app: `#${app.id}`,
|
|
74
|
+
app_name: app.name,
|
|
75
|
+
scope: scope.name,
|
|
76
|
+
scopes: scopeList,
|
|
77
|
+
lines: entries.map((entry) => ({
|
|
78
|
+
ts: entry.datetime ?? entry.timestamp ?? null,
|
|
79
|
+
message: entry.message ?? "",
|
|
80
|
+
})),
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, fmtNum, linkLine, next, spark, table } from "../md.js";
|
|
4
|
+
import { listScopes, readGoldenMetrics, windowHours } from "../np/journey.js";
|
|
5
|
+
import { defineTool, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, chooseScope, requireApp } from "./shared.js";
|
|
8
|
+
export const metricsTool = defineTool({
|
|
9
|
+
name: TOOL.applicationMetricList,
|
|
10
|
+
title: "Performance metrics",
|
|
11
|
+
description: "The golden signals of a scope — throughput (rpm), response time, error rate, CPU and memory — with sparkline trends over a time window. Use to answer 'how is it performing?' or to watch health during a rollout.",
|
|
12
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
13
|
+
widget: "metrics",
|
|
14
|
+
errorKey: "metrics.errorLabel",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
app: appArg,
|
|
17
|
+
scope: z.string().optional().describe("Scope name (defaults to the only scope)"),
|
|
18
|
+
window: z.enum(["1h", "3h", "24h", "7d"]).optional().describe("Time window (default 3h)"),
|
|
19
|
+
},
|
|
20
|
+
async handler(args, context) {
|
|
21
|
+
const resolved = await requireApp(context, args);
|
|
22
|
+
if ("out" in resolved)
|
|
23
|
+
return resolved.out;
|
|
24
|
+
const app = resolved.app;
|
|
25
|
+
const scopes = await listScopes(context.np, app.id);
|
|
26
|
+
if (scopes.length === 0) {
|
|
27
|
+
return reply(translate("metrics.noScopes", { app: app.name }) + next(translate("metrics.noScopesHint")));
|
|
28
|
+
}
|
|
29
|
+
const picked = chooseScope(scopes, args.scope, {
|
|
30
|
+
nounKey: "metrics.noun",
|
|
31
|
+
tool: TOOL.applicationMetricList,
|
|
32
|
+
app,
|
|
33
|
+
});
|
|
34
|
+
if ("out" in picked)
|
|
35
|
+
return picked.out;
|
|
36
|
+
const scope = picked.scope;
|
|
37
|
+
const window = args.window ?? "3h";
|
|
38
|
+
const series = await readGoldenMetrics(context.bff, {
|
|
39
|
+
application_id: app.id,
|
|
40
|
+
scope_id: scope.id,
|
|
41
|
+
scope_type: scope.type,
|
|
42
|
+
hours: windowHours(args.window),
|
|
43
|
+
});
|
|
44
|
+
const withData = series.filter((metric) => metric.points.length > 0);
|
|
45
|
+
if (withData.length === 0) {
|
|
46
|
+
const orgSlug = await context.org.organizationSlug();
|
|
47
|
+
return reply(translate("metrics.empty", { app: app.name, scope: scope.name, window }) +
|
|
48
|
+
linkLine(translate("metrics.openInDashboard"), dashboardLink(orgSlug, scope.nrn)), { app: `#${app.id}`, scope: scope.name, window, series: [] });
|
|
49
|
+
}
|
|
50
|
+
const markdown = [
|
|
51
|
+
translate("metrics.title", { app: app.name, scope: scope.name, window }),
|
|
52
|
+
"",
|
|
53
|
+
table([
|
|
54
|
+
translate("header.metric"),
|
|
55
|
+
translate("header.now"),
|
|
56
|
+
translate("header.avg"),
|
|
57
|
+
translate("header.max"),
|
|
58
|
+
translate("header.trend"),
|
|
59
|
+
], series.map((metric) => [
|
|
60
|
+
metric.label,
|
|
61
|
+
metric.points.length ? `${fmtNum(metric.last)} ${metric.unit}` : "—",
|
|
62
|
+
fmtNum(metric.avg),
|
|
63
|
+
fmtNum(metric.max),
|
|
64
|
+
spark(metric.points.map((point) => point.v)),
|
|
65
|
+
])),
|
|
66
|
+
].join("\n");
|
|
67
|
+
return reply(markdown, {
|
|
68
|
+
app: `#${app.id}`,
|
|
69
|
+
app_name: app.name,
|
|
70
|
+
scope: scope.name,
|
|
71
|
+
window,
|
|
72
|
+
series: series.map((metric) => ({
|
|
73
|
+
id: metric.id,
|
|
74
|
+
label: metric.label,
|
|
75
|
+
unit: metric.unit,
|
|
76
|
+
last: metric.last ?? null,
|
|
77
|
+
avg: metric.avg ?? null,
|
|
78
|
+
max: metric.max ?? null,
|
|
79
|
+
points: metric.points,
|
|
80
|
+
})),
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { ago, glyph, next, table } from "../md.js";
|
|
4
|
+
import { pmap } from "../np/context.js";
|
|
5
|
+
import { isDeploymentTerminal, listScopeDeployments, listScopes } from "../np/journey.js";
|
|
6
|
+
import { defineTool, reply } from "../tool.js";
|
|
7
|
+
import { TOOL } from "../tool-names.js";
|
|
8
|
+
/**
|
|
9
|
+
* Org-wide health digest — a question agents ask that the web has no page for: "is anything
|
|
10
|
+
* unhealthy / mid-rollout right now?". Bounded parallel fan-out over the org's apps and their
|
|
11
|
+
* scopes' latest deployments; truncation is reported, never silent.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_APPS = 30;
|
|
14
|
+
export const overviewTool = defineTool({
|
|
15
|
+
name: TOOL.organizationGet,
|
|
16
|
+
title: "Organization overview",
|
|
17
|
+
description: "A cross-application health digest for the whole org: what's mid-rollout right now, and which scopes' last deployment failed or rolled back. Answers 'is anything broken?' / 'what's deploying?' without naming an app. Scans up to the first ~30 applications.",
|
|
18
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
19
|
+
errorKey: "overview.errorLabel",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
query: z.string().optional().describe("Limit the scan to apps whose name contains this"),
|
|
22
|
+
},
|
|
23
|
+
async handler(args, context) {
|
|
24
|
+
const apps = await context.org.findApps({ query: args.query, limit: MAX_APPS + 1 });
|
|
25
|
+
const truncated = apps.length > MAX_APPS;
|
|
26
|
+
const scanned = apps.slice(0, MAX_APPS);
|
|
27
|
+
const active = [];
|
|
28
|
+
const trouble = [];
|
|
29
|
+
await pmap(scanned, async (app) => {
|
|
30
|
+
const scopes = await listScopes(context.np, app.id).catch(() => []);
|
|
31
|
+
await pmap(scopes, async (scope) => {
|
|
32
|
+
const deployments = await listScopeDeployments(context.np, scope.id, 1).catch(() => []);
|
|
33
|
+
const latest = deployments[0];
|
|
34
|
+
if (!latest)
|
|
35
|
+
return;
|
|
36
|
+
if (!isDeploymentTerminal(latest.status)) {
|
|
37
|
+
active.push({
|
|
38
|
+
app: app.name,
|
|
39
|
+
scope: scope.name,
|
|
40
|
+
deployment_id: latest.id,
|
|
41
|
+
status: latest.status,
|
|
42
|
+
traffic: latest.strategy_data?.switchedTraffic,
|
|
43
|
+
when: latest.created_at,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else if (latest.status === "failed" || latest.status === "rolled_back") {
|
|
47
|
+
trouble.push({
|
|
48
|
+
app: app.name,
|
|
49
|
+
scope: scope.name,
|
|
50
|
+
status: latest.status,
|
|
51
|
+
when: latest.created_at,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}, 6);
|
|
55
|
+
}, 8);
|
|
56
|
+
return reply(renderOverview({ active, trouble, scanned: scanned.length, truncated }), {
|
|
57
|
+
scanned: scanned.length,
|
|
58
|
+
truncated,
|
|
59
|
+
active_rollouts: active,
|
|
60
|
+
trouble,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
function renderOverview(args) {
|
|
65
|
+
const { active, trouble, scanned, truncated } = args;
|
|
66
|
+
const lines = [translate("overview.title", { count: scanned })];
|
|
67
|
+
if (truncated)
|
|
68
|
+
lines.push(translate("overview.truncated", { count: MAX_APPS }));
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push(`**${translate("overview.activeRollouts", { count: active.length })}**`);
|
|
71
|
+
if (active.length) {
|
|
72
|
+
lines.push(table([
|
|
73
|
+
translate("header.app"),
|
|
74
|
+
translate("header.scope"),
|
|
75
|
+
translate("header.deployment"),
|
|
76
|
+
translate("header.status"),
|
|
77
|
+
translate("header.traffic"),
|
|
78
|
+
translate("header.when"),
|
|
79
|
+
], active.map((rollout) => [
|
|
80
|
+
rollout.app,
|
|
81
|
+
rollout.scope,
|
|
82
|
+
`#${rollout.deployment_id}`,
|
|
83
|
+
`${glyph(rollout.status)} ${rollout.status.replace(/_/g, " ")}`,
|
|
84
|
+
rollout.traffic !== undefined ? `${rollout.traffic}%` : "",
|
|
85
|
+
ago(rollout.when),
|
|
86
|
+
])));
|
|
87
|
+
}
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(`**${translate("overview.trouble", { count: trouble.length })}**`);
|
|
90
|
+
if (trouble.length) {
|
|
91
|
+
lines.push(table([
|
|
92
|
+
translate("header.app"),
|
|
93
|
+
translate("header.scope"),
|
|
94
|
+
translate("header.status"),
|
|
95
|
+
translate("header.when"),
|
|
96
|
+
], trouble.map((entry) => [
|
|
97
|
+
entry.app,
|
|
98
|
+
entry.scope,
|
|
99
|
+
`${glyph(entry.status)} ${entry.status.replace(/_/g, " ")}`,
|
|
100
|
+
ago(entry.when),
|
|
101
|
+
])));
|
|
102
|
+
}
|
|
103
|
+
const hint = active.length
|
|
104
|
+
? translate("overview.hintActive", { app: active[0]?.app ?? "" })
|
|
105
|
+
: trouble.length
|
|
106
|
+
? translate("overview.hintTrouble", { app: trouble[0]?.app ?? "" })
|
|
107
|
+
: translate("overview.hintCalm");
|
|
108
|
+
lines.push(next(hint));
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { plural, translate } from "../i18n.js";
|
|
2
|
+
import { dashboardLink, linkLine, next, table } from "../md.js";
|
|
3
|
+
import { listParameters } from "../np/journey.js";
|
|
4
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
5
|
+
import { TOOL } from "../tool-names.js";
|
|
6
|
+
import { appArg, requireApp } from "./shared.js";
|
|
7
|
+
export const paramsTool = defineTool({
|
|
8
|
+
name: TOOL.applicationParameterList,
|
|
9
|
+
title: "List parameters",
|
|
10
|
+
description: "List an application's configuration parameters (env vars / files; secret values are masked). Use application_parameter_create to add or change them.",
|
|
11
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
12
|
+
widget: "params",
|
|
13
|
+
errorKey: "params.errorLabel",
|
|
14
|
+
inputSchema: { app: appArg },
|
|
15
|
+
async handler(args, context) {
|
|
16
|
+
const resolved = await requireApp(context, args);
|
|
17
|
+
if ("out" in resolved)
|
|
18
|
+
return resolved.out;
|
|
19
|
+
const app = resolved.app;
|
|
20
|
+
if (!app.nrn)
|
|
21
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
22
|
+
const [parameters, orgSlug] = await Promise.all([
|
|
23
|
+
listParameters(context.np, app.nrn),
|
|
24
|
+
context.org.organizationSlug(),
|
|
25
|
+
]);
|
|
26
|
+
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
27
|
+
const appRef = { app: `#${app.id}`, app_name: app.name };
|
|
28
|
+
if (parameters === undefined) {
|
|
29
|
+
return reply(translate("params.unavailable") + linkLine(translate("params.viewInDashboard"), dashboard), { available: false, ...appRef });
|
|
30
|
+
}
|
|
31
|
+
if (parameters.length === 0) {
|
|
32
|
+
return reply(translate("params.none", { app: app.name }) + next(translate("params.noneHint")), {
|
|
33
|
+
available: true,
|
|
34
|
+
params: [],
|
|
35
|
+
...appRef,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const markdown = [
|
|
39
|
+
plural(parameters.length, "params.count.one", "params.count.many", { app: app.name }),
|
|
40
|
+
"",
|
|
41
|
+
table([
|
|
42
|
+
translate("header.name"),
|
|
43
|
+
translate("header.variable"),
|
|
44
|
+
translate("header.type"),
|
|
45
|
+
translate("header.secret"),
|
|
46
|
+
translate("header.value"),
|
|
47
|
+
], parameters.map((parameter) => [
|
|
48
|
+
parameter.name,
|
|
49
|
+
parameter.variable ?? "",
|
|
50
|
+
parameter.type ?? "ENVIRONMENT",
|
|
51
|
+
parameter.secret ? "🔒" : "",
|
|
52
|
+
parameter.values[0]?.value ?? translate("params.unset"),
|
|
53
|
+
])),
|
|
54
|
+
next(translate("params.applyHint")),
|
|
55
|
+
].join("\n");
|
|
56
|
+
return reply(markdown, { available: true, params: parameters, ...appRef });
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loadSkills } from "../skills.js";
|
|
3
|
+
import { defineTool, reply } from "../tool.js";
|
|
4
|
+
import { TOOL } from "../tool-names.js";
|
|
5
|
+
/**
|
|
6
|
+
* Delivers the operating playbooks (skills/<name>/SKILL.md) to the model. Tools are the one MCP
|
|
7
|
+
* primitive every coding assistant exposes to the model, so this works uniformly across Claude
|
|
8
|
+
* Code, Cursor, Claude Desktop, etc. — unlike the old `skill://` resource, which Claude Desktop
|
|
9
|
+
* misrouted to its native Agent Skills executor ("Unknown skill"). The model is told in the
|
|
10
|
+
* instructions which playbook to read before which task; here it reads the actual methodology.
|
|
11
|
+
*/
|
|
12
|
+
/** Strip the `--- frontmatter ---` block, leaving the playbook body. */
|
|
13
|
+
const playbookBody = (markdown) => markdown.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
|
|
14
|
+
export const playbookGetTool = defineTool({
|
|
15
|
+
name: TOOL.playbookGet,
|
|
16
|
+
title: "Read an operating playbook",
|
|
17
|
+
description: "Read a nullplatform operating playbook — the methodology to follow before the matching non-trivial work: deploying-safely before a deploy/rollout, incident-response when something is broken, platform-conventions for entity/versioning/traffic semantics. Call with no name to list the available playbooks.",
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
19
|
+
errorKey: "playbook.errorLabel",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
name: z.string().optional().describe('Playbook name, e.g. "deploying-safely". Omit to list them.'),
|
|
22
|
+
},
|
|
23
|
+
async handler(args) {
|
|
24
|
+
const playbooks = loadSkills();
|
|
25
|
+
if (!args.name) {
|
|
26
|
+
const catalog = playbooks
|
|
27
|
+
.map((playbook) => `- **${playbook.name}** — ${playbook.description}`)
|
|
28
|
+
.join("\n");
|
|
29
|
+
return reply(`Operating playbooks — call \`playbook_get name:"<name>"\` to read one:\n\n${catalog}`, {
|
|
30
|
+
playbooks: playbooks.map((playbook) => ({ name: playbook.name, description: playbook.description })),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const match = playbooks.find((playbook) => playbook.name === args.name);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return reply(`No playbook named "${args.name}". Available: ${playbooks.map((playbook) => playbook.name).join(", ")}.`);
|
|
36
|
+
}
|
|
37
|
+
return reply(playbookBody(match.markdown), { name: match.name });
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { translate } from "../i18n.js";
|
|
2
|
+
import { dashboardLink, glyph, linkLine, next, table } from "../md.js";
|
|
3
|
+
import { listServiceSpecifications, listServices } from "../np/journey.js";
|
|
4
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
5
|
+
import { TOOL } from "../tool-names.js";
|
|
6
|
+
import { appArg, requireApp } from "./shared.js";
|
|
7
|
+
/**
|
|
8
|
+
* Services = an app's provisioned dependencies (databases, queues, caches…). Read-first by
|
|
9
|
+
* design: listing what's attached and what's available to provision is safe and high-value;
|
|
10
|
+
* actual provisioning/linking is a multi-step spec-driven flow with real cost, so this tool
|
|
11
|
+
* shows it and deep-links into the dashboard to create — it does not provision blind.
|
|
12
|
+
*/
|
|
13
|
+
export const servicesTool = defineTool({
|
|
14
|
+
name: TOOL.applicationServiceList,
|
|
15
|
+
title: "Services & dependencies",
|
|
16
|
+
description: "List the dependency services (databases, queues, caches…) attached to an application, plus the catalog of dependency types available to provision. Read-only: provisioning a new dependency is a guided flow, so this links you into the dashboard to create one.",
|
|
17
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
18
|
+
errorKey: "services.errorLabel",
|
|
19
|
+
inputSchema: { app: appArg },
|
|
20
|
+
async handler(args, context) {
|
|
21
|
+
const resolved = await requireApp(context, args);
|
|
22
|
+
if ("out" in resolved)
|
|
23
|
+
return resolved.out;
|
|
24
|
+
const app = resolved.app;
|
|
25
|
+
if (!app.nrn)
|
|
26
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
27
|
+
const [services, catalog, orgSlug] = await Promise.all([
|
|
28
|
+
listServices(context.bff, app.nrn),
|
|
29
|
+
listServiceSpecifications(context.bff, app.nrn),
|
|
30
|
+
context.org.organizationSlug(),
|
|
31
|
+
]);
|
|
32
|
+
const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
|
|
33
|
+
const sections = [];
|
|
34
|
+
if (services.length === 0) {
|
|
35
|
+
sections.push(translate("services.none", { app: app.name }));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
sections.push(translate("services.attached", { app: app.name, count: services.length }));
|
|
39
|
+
sections.push("");
|
|
40
|
+
sections.push(table([translate("header.name"), translate("services.specification"), translate("header.status")], services.map((service) => [
|
|
41
|
+
service.name,
|
|
42
|
+
service.specification ?? "",
|
|
43
|
+
`${glyph(service.status)} ${service.status ?? ""}`,
|
|
44
|
+
])));
|
|
45
|
+
}
|
|
46
|
+
if (catalog.length) {
|
|
47
|
+
sections.push("");
|
|
48
|
+
sections.push(translate("services.catalog", { catalog: catalog.map((spec) => spec.name).join(", ") }));
|
|
49
|
+
}
|
|
50
|
+
sections.push(next(translate("services.provisionHint")) + dashboard);
|
|
51
|
+
return reply(sections.join("\n"), {
|
|
52
|
+
app: `#${app.id}`,
|
|
53
|
+
app_name: app.name,
|
|
54
|
+
services,
|
|
55
|
+
catalog,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { next } from "../md.js";
|
|
4
|
+
import { setParameters } from "../np/journey.js";
|
|
5
|
+
import { defineTool, errorMessage, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, requireApp } from "./shared.js";
|
|
8
|
+
export const setParamsTool = defineTool({
|
|
9
|
+
name: TOOL.applicationParameterCreate,
|
|
10
|
+
title: "Set parameters",
|
|
11
|
+
description: "Create or update application configuration parameters (environment variables or files; mark secrets). Values apply on the NEXT deploy.",
|
|
12
|
+
annotations: { destructiveHint: false, openWorldHint: true },
|
|
13
|
+
errorKey: "setParams.errorLabel",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
app: appArg,
|
|
16
|
+
params: z
|
|
17
|
+
.array(z.object({
|
|
18
|
+
name: z.string().describe("Variable name, e.g. DATABASE_URL"),
|
|
19
|
+
value: z.string(),
|
|
20
|
+
secret: z.boolean().optional().describe("Mask the value (default false)"),
|
|
21
|
+
type: z.enum(["ENVIRONMENT", "FILE"]).optional().describe("Default ENVIRONMENT"),
|
|
22
|
+
}))
|
|
23
|
+
.min(1),
|
|
24
|
+
},
|
|
25
|
+
async handler(args, context) {
|
|
26
|
+
const resolved = await requireApp(context, args);
|
|
27
|
+
if ("out" in resolved)
|
|
28
|
+
return resolved.out;
|
|
29
|
+
const app = resolved.app;
|
|
30
|
+
if (!app.nrn)
|
|
31
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
32
|
+
try {
|
|
33
|
+
const result = await setParameters(context.np, app.nrn, args.params);
|
|
34
|
+
const names = args.params.map((parameter) => `\`${parameter.name}\``).join(", ");
|
|
35
|
+
const total = result.created + result.updated;
|
|
36
|
+
// Honest about upsert: say how many were new vs changed so a retry reads as "0 new".
|
|
37
|
+
const summary = result.updated === 0
|
|
38
|
+
? translate("setParams.created", { count: result.created, app: app.name, names })
|
|
39
|
+
: result.created === 0
|
|
40
|
+
? translate("setParams.updated", { count: result.updated, app: app.name, names })
|
|
41
|
+
: translate("setParams.mixed", {
|
|
42
|
+
created: result.created,
|
|
43
|
+
updated: result.updated,
|
|
44
|
+
app: app.name,
|
|
45
|
+
names,
|
|
46
|
+
});
|
|
47
|
+
return reply(summary + next(translate("setParams.applyHint")), {
|
|
48
|
+
created: result.created,
|
|
49
|
+
updated: result.updated,
|
|
50
|
+
total,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (caught) {
|
|
54
|
+
// Parameters are applied one by one — a midway failure leaves the earlier ones in place.
|
|
55
|
+
return fail(translate("setParams.partial", { message: errorMessage(caught) }));
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|