@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
package/dist/render.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { translate } from "./i18n.js";
|
|
2
|
+
import { ago, glyph, linkLine, next, shortCommit, statusLabel, table } from "./md.js";
|
|
3
|
+
import { isDeploymentTerminal } from "./np/journey.js";
|
|
4
|
+
export function renderAppHeader(app) {
|
|
5
|
+
const where = [app.namespace, app.account].filter(Boolean).join(" / ");
|
|
6
|
+
const repo = app.repository_url ? `\nrepo ${app.repository_url}` : "";
|
|
7
|
+
return `**${app.name}** · #${app.id}${where ? ` · ${where}` : ""}${repo}`;
|
|
8
|
+
}
|
|
9
|
+
function trafficBar(percent) {
|
|
10
|
+
if (percent === undefined || percent === null || Number.isNaN(percent))
|
|
11
|
+
return "";
|
|
12
|
+
const filled = Math.round(percent / 10);
|
|
13
|
+
return `\`${"▓".repeat(filled)}${"░".repeat(10 - filled)}\` ${percent}%`;
|
|
14
|
+
}
|
|
15
|
+
function releaseLabel(release, releaseId) {
|
|
16
|
+
if (release)
|
|
17
|
+
return release.semver;
|
|
18
|
+
return releaseId ? `release #${releaseId}` : "—";
|
|
19
|
+
}
|
|
20
|
+
export function renderScopeRows(views) {
|
|
21
|
+
if (views.length === 0)
|
|
22
|
+
return translate("render.noScopes");
|
|
23
|
+
const domains = views
|
|
24
|
+
.filter((view) => view.scope.domain && view.current && view.current.status === "finalized")
|
|
25
|
+
.map((view) => `- ${view.scope.name}: https://${view.scope.domain}`);
|
|
26
|
+
const scopeTable = table([
|
|
27
|
+
translate("header.scope"),
|
|
28
|
+
translate("header.status"),
|
|
29
|
+
translate("header.live"),
|
|
30
|
+
translate("header.traffic"),
|
|
31
|
+
translate("header.when"),
|
|
32
|
+
], views.map((view) => {
|
|
33
|
+
const deployment = view.current;
|
|
34
|
+
const rollingOut = deployment && !isDeploymentTerminal(deployment.status);
|
|
35
|
+
const traffic = deployment?.strategy_data?.switchedTraffic;
|
|
36
|
+
const dimensions = Object.values(view.scope.dimensions ?? {}).join("/");
|
|
37
|
+
return [
|
|
38
|
+
view.scope.name + (dimensions ? ` (${dimensions})` : ""),
|
|
39
|
+
rollingOut ? statusLabel(deployment.status) : statusLabel(view.scope.status),
|
|
40
|
+
deployment
|
|
41
|
+
? releaseLabel(view.live_release, deployment.release_id) + (rollingOut ? " ⏳" : "")
|
|
42
|
+
: translate("render.nothingDeployed"),
|
|
43
|
+
traffic !== undefined ? `${traffic}%` : "",
|
|
44
|
+
ago(deployment?.created_at),
|
|
45
|
+
];
|
|
46
|
+
}));
|
|
47
|
+
return domains.length ? `${scopeTable}\n${domains.join("\n")}` : scopeTable;
|
|
48
|
+
}
|
|
49
|
+
export function statusNextHint(args) {
|
|
50
|
+
const { views, latestBuild, latestRelease } = args;
|
|
51
|
+
const activeView = views.find((view) => view.current && !isDeploymentTerminal(view.current.status));
|
|
52
|
+
if (activeView?.current) {
|
|
53
|
+
const deployment = activeView.current;
|
|
54
|
+
const traffic = deployment.strategy_data?.switchedTraffic;
|
|
55
|
+
if (deployment.status === "running" && traffic !== undefined && traffic < 100) {
|
|
56
|
+
return translate("hint.rolloutTraffic", {
|
|
57
|
+
id: deployment.id,
|
|
58
|
+
scope: activeView.scope.name,
|
|
59
|
+
traffic,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return translate("hint.rolloutStatus", {
|
|
63
|
+
id: deployment.id,
|
|
64
|
+
scope: activeView.scope.name,
|
|
65
|
+
status: deployment.status.replace(/_/g, " "),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (views.length === 0)
|
|
69
|
+
return translate("hint.noScopes");
|
|
70
|
+
if (latestBuild?.status === "successful" && latestRelease && latestRelease.build_id !== latestBuild.id) {
|
|
71
|
+
return translate("hint.buildNewer", {
|
|
72
|
+
id: latestBuild.id,
|
|
73
|
+
branch: latestBuild.branch ?? "?",
|
|
74
|
+
commit: shortCommit(latestBuild.commit),
|
|
75
|
+
semver: latestRelease.semver,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (latestBuild?.status === "successful" && !latestRelease) {
|
|
79
|
+
return translate("hint.buildUnreleased", { id: latestBuild.id });
|
|
80
|
+
}
|
|
81
|
+
if (latestRelease) {
|
|
82
|
+
const liveSomewhere = views.some((view) => view.current && view.current.release_id === latestRelease.id);
|
|
83
|
+
if (!liveSomewhere)
|
|
84
|
+
return translate("hint.releaseNotLive", { semver: latestRelease.semver });
|
|
85
|
+
}
|
|
86
|
+
if (!latestBuild)
|
|
87
|
+
return translate("hint.noBuilds");
|
|
88
|
+
return translate("hint.upToDate");
|
|
89
|
+
}
|
|
90
|
+
export function renderStatus(args) {
|
|
91
|
+
const { app, views, builds, releases, dashboard } = args;
|
|
92
|
+
const latestBuild = builds[0];
|
|
93
|
+
const latestRelease = releases[0];
|
|
94
|
+
const liveOn = (releaseId) => views.filter((view) => view.current?.release_id === releaseId).map((view) => view.scope.name);
|
|
95
|
+
const releaseList = releases
|
|
96
|
+
.slice(0, 4)
|
|
97
|
+
.map((release) => {
|
|
98
|
+
const scopes = liveOn(release.id);
|
|
99
|
+
return `${release.semver}${scopes.length
|
|
100
|
+
? ` ${glyph("active")} ${translate("render.liveOn", { scopes: scopes.join(", ") })}`
|
|
101
|
+
: ""}`;
|
|
102
|
+
})
|
|
103
|
+
.join(" · ");
|
|
104
|
+
const latestBuildLine = latestBuild
|
|
105
|
+
? `${glyph(latestBuild.status)} #${latestBuild.id} ${latestBuild.branch ?? ""} @${shortCommit(latestBuild.commit)} (${ago(latestBuild.created_at)})`
|
|
106
|
+
: translate("render.noBuilds");
|
|
107
|
+
const lines = [
|
|
108
|
+
renderAppHeader(app),
|
|
109
|
+
"",
|
|
110
|
+
renderScopeRows(views),
|
|
111
|
+
"",
|
|
112
|
+
`**${translate("render.latestBuild")}** ${latestBuildLine}`,
|
|
113
|
+
`**${translate("render.releases")}** ${releaseList || translate("md.none")}`,
|
|
114
|
+
next(statusNextHint({ views, latestBuild, latestRelease })) +
|
|
115
|
+
linkLine(translate("md.dashboard"), dashboard),
|
|
116
|
+
];
|
|
117
|
+
return {
|
|
118
|
+
md: lines.join("\n"),
|
|
119
|
+
structured: {
|
|
120
|
+
application: app,
|
|
121
|
+
scopes: views.map((view) => ({
|
|
122
|
+
...view.scope,
|
|
123
|
+
current_deployment: view.current
|
|
124
|
+
? {
|
|
125
|
+
id: view.current.id,
|
|
126
|
+
status: view.current.status,
|
|
127
|
+
release_id: view.current.release_id,
|
|
128
|
+
traffic: view.current.strategy_data?.switchedTraffic,
|
|
129
|
+
}
|
|
130
|
+
: null,
|
|
131
|
+
live_release: view.live_release ?? null,
|
|
132
|
+
})),
|
|
133
|
+
latest_build: latestBuild ?? null,
|
|
134
|
+
releases: releases.map((release) => ({
|
|
135
|
+
id: release.id,
|
|
136
|
+
semver: release.semver,
|
|
137
|
+
status: release.status,
|
|
138
|
+
created_at: release.created_at,
|
|
139
|
+
live_on: liveOn(release.id),
|
|
140
|
+
})),
|
|
141
|
+
hint: statusNextHint({ views, latestBuild, latestRelease }),
|
|
142
|
+
dashboard: dashboard ?? null,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function rolloutNextHint(deployment) {
|
|
147
|
+
const traffic = deployment.strategy_data?.switchedTraffic;
|
|
148
|
+
const desired = deployment.strategy_data?.desiredSwitchedTraffic;
|
|
149
|
+
switch (deployment.status) {
|
|
150
|
+
case "running":
|
|
151
|
+
if (traffic === 100)
|
|
152
|
+
return translate("rollout.full");
|
|
153
|
+
if (desired !== undefined && desired !== traffic) {
|
|
154
|
+
return translate("rollout.moving", { from: traffic ?? 0, to: desired, id: deployment.id });
|
|
155
|
+
}
|
|
156
|
+
return translate("rollout.at", { traffic: traffic ?? 0 });
|
|
157
|
+
case "creating":
|
|
158
|
+
case "waiting_for_instances":
|
|
159
|
+
return translate("rollout.instances", { id: deployment.id });
|
|
160
|
+
case "switching_traffic":
|
|
161
|
+
return translate("rollout.switching", { id: deployment.id });
|
|
162
|
+
case "finalizing":
|
|
163
|
+
return translate("rollout.finalizing");
|
|
164
|
+
case "finalized":
|
|
165
|
+
return translate("rollout.done");
|
|
166
|
+
case "creating_approval":
|
|
167
|
+
return translate("rollout.approval");
|
|
168
|
+
case "failed":
|
|
169
|
+
return translate("rollout.failed");
|
|
170
|
+
case "cancelled":
|
|
171
|
+
case "rolled_back":
|
|
172
|
+
return translate("rollout.rolledBack");
|
|
173
|
+
default:
|
|
174
|
+
return translate("hint.watch", { id: deployment.id });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function renderRollout(args) {
|
|
178
|
+
const { deployment, scope, release, dashboard } = args;
|
|
179
|
+
const traffic = deployment.strategy_data?.switchedTraffic;
|
|
180
|
+
const desired = deployment.strategy_data?.desiredSwitchedTraffic;
|
|
181
|
+
const recent = (deployment.messages ?? []).slice(-5).map((entry) => {
|
|
182
|
+
const time = entry.timestamp ? new Date(entry.timestamp).toISOString().slice(11, 19) : "";
|
|
183
|
+
return `- ${time} ${entry.source ? `[${entry.source}] ` : ""}${entry.message}`.trim();
|
|
184
|
+
});
|
|
185
|
+
const startedWhen = ago(deployment.created_at) || translate("render.now");
|
|
186
|
+
const lines = [
|
|
187
|
+
`${args.title ?? translate("render.deployment")} #${deployment.id}${scope ? ` on **${scope.name}**` : ""} — ${statusLabel(deployment.status)}`,
|
|
188
|
+
release
|
|
189
|
+
? `${translate("render.release")} **${release.semver}** · ${translate("render.started", { when: startedWhen })}`
|
|
190
|
+
: translate("render.started", { when: startedWhen }),
|
|
191
|
+
traffic !== undefined
|
|
192
|
+
? `${translate("header.traffic")} ${trafficBar(traffic)}${desired !== undefined && desired !== traffic
|
|
193
|
+
? ` ${translate("render.desired", { pct: desired })}`
|
|
194
|
+
: ""}`
|
|
195
|
+
: "",
|
|
196
|
+
recent.length ? `\n${recent.join("\n")}` : "",
|
|
197
|
+
next(rolloutNextHint(deployment)) + linkLine(translate("md.dashboard"), dashboard),
|
|
198
|
+
].filter(Boolean);
|
|
199
|
+
return {
|
|
200
|
+
md: lines.join("\n"),
|
|
201
|
+
structured: {
|
|
202
|
+
deployment: {
|
|
203
|
+
id: deployment.id,
|
|
204
|
+
status: deployment.status,
|
|
205
|
+
terminal: isDeploymentTerminal(deployment.status),
|
|
206
|
+
traffic: traffic ?? null,
|
|
207
|
+
desired_traffic: desired ?? null,
|
|
208
|
+
scope_id: deployment.scope_id ?? scope?.id ?? null,
|
|
209
|
+
scope_name: scope?.name ?? null,
|
|
210
|
+
application_id: scope?.application_id ?? null,
|
|
211
|
+
release_id: deployment.release_id ?? null,
|
|
212
|
+
release_semver: release?.semver ?? null,
|
|
213
|
+
messages: (deployment.messages ?? []).slice(-40).map((entry) => ({
|
|
214
|
+
timestamp: entry.timestamp ?? null,
|
|
215
|
+
source: entry.source ?? null,
|
|
216
|
+
message: entry.message,
|
|
217
|
+
})),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function renderAppsTable(apps) {
|
|
223
|
+
return table([
|
|
224
|
+
translate("header.app"),
|
|
225
|
+
translate("header.id"),
|
|
226
|
+
translate("header.namespace"),
|
|
227
|
+
translate("header.account"),
|
|
228
|
+
translate("header.status"),
|
|
229
|
+
], apps.map((app) => [
|
|
230
|
+
app.name,
|
|
231
|
+
`#${app.id}`,
|
|
232
|
+
app.namespace ?? "",
|
|
233
|
+
app.account ?? "",
|
|
234
|
+
statusLabel(app.status),
|
|
235
|
+
]));
|
|
236
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { detectRepoUrl } from "./git.js";
|
|
3
|
+
import { TokenManager } from "./np/auth.js";
|
|
4
|
+
import { NpClient } from "./np/client.js";
|
|
5
|
+
import { NpContext } from "./np/context.js";
|
|
6
|
+
import { loadSkills, registerPlaybookResources, skillsInstructionBlock } from "./skills.js";
|
|
7
|
+
import { defaultSurface } from "./surfaces/index.js";
|
|
8
|
+
import { registerTools } from "./tool.js";
|
|
9
|
+
import { EXTENSION_ID, RESOURCE_MIME_TYPE, registerWidgets, uiNegotiated } from "./ui.js";
|
|
10
|
+
export function buildDeps(config, fetchImpl = fetch, tokenSource) {
|
|
11
|
+
const tokens = tokenSource ??
|
|
12
|
+
new TokenManager({ apiBase: config.apiBase, apiKey: config.apiKey, bearer: config.bearer }, fetchImpl);
|
|
13
|
+
const np = new NpClient({
|
|
14
|
+
apiBase: config.apiBase,
|
|
15
|
+
getToken: () => tokens.getToken(),
|
|
16
|
+
onUnauthorized: async () => tokens.invalidate(),
|
|
17
|
+
}, fetchImpl);
|
|
18
|
+
const org = new NpContext(np);
|
|
19
|
+
const bff = new NpClient({
|
|
20
|
+
apiBase: config.bffBase,
|
|
21
|
+
getToken: () => tokens.getToken(),
|
|
22
|
+
onUnauthorized: async () => tokens.invalidate(),
|
|
23
|
+
getExtraHeaders: async () => {
|
|
24
|
+
const orgSlug = await org.organizationSlug();
|
|
25
|
+
return orgSlug ? { "Np-Organization-Slug": orgSlug } : {};
|
|
26
|
+
},
|
|
27
|
+
}, fetchImpl);
|
|
28
|
+
return { np, org, bff, tokens };
|
|
29
|
+
}
|
|
30
|
+
export function buildServer(deps, options = {}) {
|
|
31
|
+
const surface = options.surface ?? defaultSurface;
|
|
32
|
+
// Operating playbooks (skills/*.md) are inlined straight into the instructions below, so the
|
|
33
|
+
// model always has the methodology in context — nothing to fetch, nothing to "run as a skill".
|
|
34
|
+
const skills = loadSkills();
|
|
35
|
+
const server = new McpServer({ name: surface.serverName, version: "0.1.0" }, {
|
|
36
|
+
capabilities: {
|
|
37
|
+
logging: {},
|
|
38
|
+
// MCP Apps capability negotiation (SEP-1724/SEP-1865): hosts gate widget
|
|
39
|
+
// rendering on the server advertising the ui extension.
|
|
40
|
+
extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
|
|
41
|
+
},
|
|
42
|
+
instructions: surface.instructions + skillsInstructionBlock(skills),
|
|
43
|
+
});
|
|
44
|
+
// Workspace repo detection: prefer the client's MCP roots, fall back to our cwd.
|
|
45
|
+
let cachedRepoUrl = null;
|
|
46
|
+
const repoUrl = async () => {
|
|
47
|
+
if (cachedRepoUrl && Date.now() - cachedRepoUrl.at < 30_000)
|
|
48
|
+
return cachedRepoUrl.url;
|
|
49
|
+
let url;
|
|
50
|
+
try {
|
|
51
|
+
const clientCapabilities = server.server.getClientCapabilities();
|
|
52
|
+
if (clientCapabilities?.roots) {
|
|
53
|
+
const { roots } = await server.server.listRoots();
|
|
54
|
+
for (const root of roots ?? []) {
|
|
55
|
+
const path = root.uri.startsWith("file://")
|
|
56
|
+
? decodeURIComponent(new URL(root.uri).pathname)
|
|
57
|
+
: root.uri;
|
|
58
|
+
url = await detectRepoUrl(path);
|
|
59
|
+
if (url)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* client without roots support */
|
|
66
|
+
}
|
|
67
|
+
if (!url)
|
|
68
|
+
url = await detectRepoUrl(options.cwd ?? process.cwd());
|
|
69
|
+
cachedRepoUrl = { at: Date.now(), url };
|
|
70
|
+
return url;
|
|
71
|
+
};
|
|
72
|
+
const toolContext = { np: deps.np, org: deps.org, bff: deps.bff, repoUrl };
|
|
73
|
+
registerTools(server, surface.tools, toolContext);
|
|
74
|
+
surface.registerPrompts(server);
|
|
75
|
+
// Passive playbook:// resources (the model reads playbooks via the playbook_get tool, not these);
|
|
76
|
+
// also the one build-time resource that keeps resources/list available for widget negotiation.
|
|
77
|
+
registerPlaybookResources(server);
|
|
78
|
+
// ui mode: widgets bound by the tool specs. Text-only hosts never see them in
|
|
79
|
+
// "negotiated" mode; in "eager" mode they simply ignore the resources (per spec).
|
|
80
|
+
const widgetNames = surface.tools.flatMap((tool) => (tool.widget ? [tool.widget] : []));
|
|
81
|
+
if ((options.uiRegistration ?? "eager") === "eager") {
|
|
82
|
+
registerWidgets(server, widgetNames);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
server.server.oninitialized = () => {
|
|
86
|
+
if (uiNegotiated(server))
|
|
87
|
+
registerWidgets(server, widgetNames);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return server;
|
|
91
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Operating playbooks — markdown methodology under skills/<name>/SKILL.md, edited by the platform
|
|
6
|
+
* team without touching server code.
|
|
7
|
+
*
|
|
8
|
+
* THE MODEL reads them through the `playbook_get` TOOL (see src/tools/playbook.ts): tools are the
|
|
9
|
+
* one MCP primitive every coding assistant exposes to the model, so this works uniformly across
|
|
10
|
+
* Claude Code, Cursor, Claude Desktop, etc. The server instructions carry the catalog so the model
|
|
11
|
+
* knows which playbook to read before which task; the tool returns the text on demand.
|
|
12
|
+
*
|
|
13
|
+
* They are ALSO registered as passive `playbook://nullplatform/<name>` resources — NOT how the
|
|
14
|
+
* model gets them (it never sees these URIs), but a standard listing for clients/users, and it
|
|
15
|
+
* keeps the server's `resources` capability alive for widget negotiation. The neutral
|
|
16
|
+
* `playbook://` scheme is deliberate: the old `skill://` scheme made Claude Desktop route to its
|
|
17
|
+
* native Agent Skills executor ("Unknown skill") instead of reading the resource.
|
|
18
|
+
*/
|
|
19
|
+
const skillsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
20
|
+
function frontmatter(markdown) {
|
|
21
|
+
const block = /^---\n([\s\S]*?)\n---/.exec(markdown);
|
|
22
|
+
const fields = {};
|
|
23
|
+
if (!block?.[1])
|
|
24
|
+
return fields;
|
|
25
|
+
for (const line of block[1].split("\n")) {
|
|
26
|
+
const field = /^([a-zA-Z_-]+):\s*(.+)$/.exec(line);
|
|
27
|
+
if (field?.[1] && field[2] !== undefined)
|
|
28
|
+
fields[field[1]] = field[2].trim();
|
|
29
|
+
}
|
|
30
|
+
return fields;
|
|
31
|
+
}
|
|
32
|
+
export function loadSkills() {
|
|
33
|
+
let dirs;
|
|
34
|
+
try {
|
|
35
|
+
dirs = readdirSync(skillsDir).filter((entry) => {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(join(skillsDir, entry)).isDirectory();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const skills = [];
|
|
48
|
+
for (const dir of dirs) {
|
|
49
|
+
try {
|
|
50
|
+
const markdown = readFileSync(join(skillsDir, dir, "SKILL.md"), "utf8");
|
|
51
|
+
const fields = frontmatter(markdown);
|
|
52
|
+
skills.push({ name: fields.name ?? dir, description: fields.description ?? "", markdown });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* directory without SKILL.md */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return skills;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Register the playbooks as passive `playbook://` resources — a standard listing, and the only
|
|
62
|
+
* resource registered at build time, which keeps `resources/list` working for negotiated widget
|
|
63
|
+
* sessions. The model is pointed at the `playbook_get` tool, never at these URIs.
|
|
64
|
+
*/
|
|
65
|
+
export function registerPlaybookResources(server) {
|
|
66
|
+
for (const skill of loadSkills()) {
|
|
67
|
+
const uri = `playbook://nullplatform/${skill.name}`;
|
|
68
|
+
server.registerResource(`Playbook: ${skill.name}`, uri, { description: skill.description, mimeType: "text/markdown" }, async () => ({ contents: [{ uri, mimeType: "text/markdown", text: skill.markdown }] }));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The playbook catalog appended to the server instructions — names + when-to-use, plus the
|
|
73
|
+
* pointer to the `playbook_get` tool. No skill:// URIs and no "skill" wording (that is exactly
|
|
74
|
+
* what made Claude Desktop try to execute a skill).
|
|
75
|
+
*/
|
|
76
|
+
export function skillsInstructionBlock(skills) {
|
|
77
|
+
if (!skills.length)
|
|
78
|
+
return "";
|
|
79
|
+
const catalog = skills.map((skill) => `- \`${skill.name}\` — ${skill.description}`).join("\n");
|
|
80
|
+
return ("\n\n# Operating playbooks\n\nThese encode how to do non-trivial work safely. BEFORE the matching " +
|
|
81
|
+
'task, call the `playbook_get` tool to read the relevant one (e.g. `playbook_get name:"deploying-safely"` ' +
|
|
82
|
+
"before any deploy) and follow it — do not improvise:\n" +
|
|
83
|
+
catalog);
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { registerPrompts } from "../prompts.js";
|
|
2
|
+
import { tools } from "../tools/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* The developer surface — the flagship `@nullplatform/mcp`. Audience: app developers
|
|
5
|
+
* driving build → release → deploy → observe of *their* application from a code assistant.
|
|
6
|
+
*/
|
|
7
|
+
const INSTRUCTIONS = `nullplatform is where this code gets built, released, deployed and observed — these tools replace its web dashboard for the everyday developer journey.
|
|
8
|
+
|
|
9
|
+
The tools are repo-aware: inside a git repo, omit \`app\` and the linked application is inferred from the git remote. Start with \`application_get\` — it shows what's live where and suggests the next action.
|
|
10
|
+
|
|
11
|
+
Every tool accepts \`language\`: ALWAYS set it to the language the user is conversing in (ISO code, e.g. "es", "en") — answers come back in the user's language.
|
|
12
|
+
|
|
13
|
+
Most tools render an interactive panel in clients that support it — the user sees apps, status, logs, metrics and parameters as live UI. When that panel renders, the user can already see the result: do NOT restate or summarise it in text (no re-listing applications, reprinting tables, or repeating status/metrics). Reply with at most one short sentence — the single key takeaway or next step — or nothing at all. Add prose only for something the panel doesn't already show.
|
|
14
|
+
|
|
15
|
+
When the user wants to create, scaffold, set up or import an application, call \`application_create\` right away — pass a name if they gave one, otherwise no arguments. Its panel is an interactive FORM that collects the namespace, template and repository itself. Do NOT gather those details in conversation first, and while that form is on screen do NOT ask the user clarifying questions or use any question-asking tool for fields the form already covers (namespace, template, new-vs-import repository, monorepo path) — the form is the input surface. Just call \`application_create\` and let it drive; react only to what it reports back.
|
|
16
|
+
|
|
17
|
+
Typical flows:
|
|
18
|
+
- Ship: \`application_deployment_create\` (picks the latest build, cuts the release for you) → \`application_deployment_update percent:25/50/100\` → \`application_deployment_update action:"finalize"\`.
|
|
19
|
+
- First time on a repo: \`application_create\` → push a commit (CI builds) → \`application_deployment_create\`.
|
|
20
|
+
- Trouble: \`application_get\` → \`application_log_list\` + \`application_metric_list\` (golden signals) → \`application_deployment_update action:"rollback"\` if needed.
|
|
21
|
+
|
|
22
|
+
\`application_deployment_create\`/\`application_scope_create\` provision real infrastructure. Rollback is the safety hatch — it returns traffic to the previous version.`;
|
|
23
|
+
export const developerSurface = {
|
|
24
|
+
key: "developer",
|
|
25
|
+
serverName: "nullplatform",
|
|
26
|
+
instructions: INSTRUCTIONS,
|
|
27
|
+
tools,
|
|
28
|
+
registerPrompts,
|
|
29
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { developerSurface } from "./developer.js";
|
|
2
|
+
/**
|
|
3
|
+
* The surface registry — the extension point for new audiences. To add one (operator,
|
|
4
|
+
* insights): create src/surfaces/<name>.ts exporting a Surface and list it here. The entry
|
|
5
|
+
* point selects a surface by key (NP_SURFACE); a sibling published package just defaults to
|
|
6
|
+
* a different key.
|
|
7
|
+
*/
|
|
8
|
+
export const surfaces = {
|
|
9
|
+
developer: developerSurface,
|
|
10
|
+
};
|
|
11
|
+
export const defaultSurface = developerSurface;
|
|
12
|
+
/** Resolve a surface by key, falling back to the developer surface. */
|
|
13
|
+
export function selectSurface(key) {
|
|
14
|
+
if (key && key in surfaces)
|
|
15
|
+
return surfaces[key];
|
|
16
|
+
return defaultSurface;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the model-facing tool names — `<entity>[_<subentity>]_<action>`
|
|
3
|
+
* (the convention is documented in CLAUDE.md). Every tool definition, widget `callServerTool`
|
|
4
|
+
* and test references a `TOOL.*` member instead of re-typing the literal, so a rename is a
|
|
5
|
+
* one-line change here and a typo can't silently break a widget's tool call. This is a pure
|
|
6
|
+
* leaf module (no imports) so both the server build and the sandboxed widget bundle can share it.
|
|
7
|
+
*/
|
|
8
|
+
export const TOOL = {
|
|
9
|
+
applicationCreate: "application_create",
|
|
10
|
+
applicationList: "application_list",
|
|
11
|
+
applicationGet: "application_get",
|
|
12
|
+
applicationLogList: "application_log_list",
|
|
13
|
+
applicationMetricList: "application_metric_list",
|
|
14
|
+
applicationBuildList: "application_build_list",
|
|
15
|
+
applicationParameterList: "application_parameter_list",
|
|
16
|
+
applicationParameterCreate: "application_parameter_create",
|
|
17
|
+
applicationDeploymentCreate: "application_deployment_create",
|
|
18
|
+
applicationDeploymentUpdate: "application_deployment_update",
|
|
19
|
+
applicationReleaseCreate: "application_release_create",
|
|
20
|
+
applicationScopeCreate: "application_scope_create",
|
|
21
|
+
applicationServiceList: "application_service_list",
|
|
22
|
+
applicationApprovalList: "application_approval_list",
|
|
23
|
+
organizationGet: "organization_get",
|
|
24
|
+
playbookGet: "playbook_get",
|
|
25
|
+
};
|
package/dist/tool.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { currentLocale, matchLocale, translate, withLocale } from "./i18n.js";
|
|
3
|
+
import { uiMeta, widgetUri } from "./ui.js";
|
|
4
|
+
/** Markdown for the human + JSON for the model/widget. */
|
|
5
|
+
export function reply(markdown, data) {
|
|
6
|
+
return { markdown, ...(data ? { data } : {}) };
|
|
7
|
+
}
|
|
8
|
+
export function fail(markdown, data) {
|
|
9
|
+
return { ...reply(markdown, data), isError: true };
|
|
10
|
+
}
|
|
11
|
+
/** Identity helper: full arg-type inference inside the spec, erased registry type outside. */
|
|
12
|
+
export function defineTool(spec) {
|
|
13
|
+
// Sound in practice: the SDK validates args against the same inputSchema before calling.
|
|
14
|
+
return spec;
|
|
15
|
+
}
|
|
16
|
+
/** ToolReply -> wire result. The single place replies become protocol shapes. */
|
|
17
|
+
export function present(toolReply) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: toolReply.markdown.trim() }],
|
|
20
|
+
...(toolReply.data ? { structuredContent: toolReply.data } : {}),
|
|
21
|
+
...(toolReply.isError ? { isError: true } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Injected into every tool: only the model knows what language the user is conversing
|
|
26
|
+
* in, so the model declares it per call and replies come back localized. Takes priority
|
|
27
|
+
* over the transport/env locale.
|
|
28
|
+
*/
|
|
29
|
+
const languageArg = z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('Reply language — the language the user is conversing in, as an ISO code (e.g. "en", "es"). Set it on every call.');
|
|
33
|
+
export function registerTools(server, tools, context) {
|
|
34
|
+
for (const tool of tools) {
|
|
35
|
+
if ("language" in tool.inputSchema) {
|
|
36
|
+
throw new Error(`tool "${tool.name}" must not define its own "language" argument — the framework injects it`);
|
|
37
|
+
}
|
|
38
|
+
server.registerTool(tool.name, {
|
|
39
|
+
title: tool.title,
|
|
40
|
+
description: tool.description,
|
|
41
|
+
annotations: tool.annotations,
|
|
42
|
+
...(tool.widget ? { _meta: uiMeta(widgetUri(tool.widget)) } : {}),
|
|
43
|
+
inputSchema: { ...tool.inputSchema, language: languageArg },
|
|
44
|
+
}, async (rawArgs) => present(await runInUserLanguage(tool, rawArgs, context)));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Honour the LLM-declared conversation language, falling back to the ambient locale. */
|
|
48
|
+
async function runInUserLanguage(tool, rawArgs, context) {
|
|
49
|
+
const { language, ...args } = (rawArgs ?? {});
|
|
50
|
+
const requested = typeof language === "string" ? matchLocale(language) : undefined;
|
|
51
|
+
const locale = requested ?? currentLocale();
|
|
52
|
+
const toolReply = requested
|
|
53
|
+
? await withLocale(requested, () => run(tool, args, context))
|
|
54
|
+
: await run(tool, args, context);
|
|
55
|
+
// Stamp the effective locale so ui-mode widgets can localize their own chrome
|
|
56
|
+
// (the data channel is the only way the locale reaches the sandboxed widget).
|
|
57
|
+
if (toolReply.data)
|
|
58
|
+
toolReply.data._locale = locale;
|
|
59
|
+
return toolReply;
|
|
60
|
+
}
|
|
61
|
+
/** One error net for every tool — no reply ever escapes as a raw protocol error. */
|
|
62
|
+
async function run(tool, args, context) {
|
|
63
|
+
try {
|
|
64
|
+
return await tool.handler(args, context);
|
|
65
|
+
}
|
|
66
|
+
catch (caught) {
|
|
67
|
+
const message = errorMessage(caught);
|
|
68
|
+
return tool.onError?.(message) ?? fail(`${translate(tool.errorKey)}: ${message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Bound a detail string: an unexpected or large upstream body must not flood the answer. */
|
|
72
|
+
function clampDetail(text, max = 300) {
|
|
73
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
74
|
+
}
|
|
75
|
+
export function errorMessage(caught) {
|
|
76
|
+
const failure = caught;
|
|
77
|
+
const body = failure?.body;
|
|
78
|
+
const detail = body && typeof body === "object" ? (body.message ?? body.error ?? JSON.stringify(body)) : body;
|
|
79
|
+
// RBAC is the platform's, enforced with the caller's own token — say so plainly.
|
|
80
|
+
const friendly = failure?.status === 401
|
|
81
|
+
? translate("error.credentialRejected", { status: 401 })
|
|
82
|
+
: failure?.status === 403
|
|
83
|
+
? translate("error.permission")
|
|
84
|
+
: "";
|
|
85
|
+
return [
|
|
86
|
+
friendly,
|
|
87
|
+
failure?.message ?? String(caught),
|
|
88
|
+
detail && detail !== failure?.message ? clampDetail(String(detail)) : "",
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(" — ");
|
|
92
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { ago, dashboardLink, glyph, linkLine, next, table } from "../md.js";
|
|
4
|
+
import { cancelApproval, executeApproval, isSafeApprovalId, listApprovals } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, requireApp } from "./shared.js";
|
|
8
|
+
/**
|
|
9
|
+
* Approvals as a first-class loop. On the web an approval is a notification; for an agent
|
|
10
|
+
* driving a deploy it's a blocking state it must see and (where RBAC allows) act on. List
|
|
11
|
+
* is fully grounded; approve/cancel call the exact endpoints the dashboard uses, gated by
|
|
12
|
+
* the caller's own token — so a user without permission gets the platform's 403 verbatim.
|
|
13
|
+
*/
|
|
14
|
+
export const approvalsTool = defineTool({
|
|
15
|
+
name: TOOL.applicationApprovalList,
|
|
16
|
+
title: "Approvals",
|
|
17
|
+
description: 'List the approvals gating an application\'s actions (e.g. a deployment waiting on a policy), and act on one. action:"approve" lets the gated action proceed; action:"cancel" cancels the request. Both use your own permissions — the platform denies what you\'re not allowed to do. Use this when a deploy is stuck in creating_approval.',
|
|
18
|
+
annotations: { destructiveHint: false, openWorldHint: true },
|
|
19
|
+
errorKey: "approvals.errorLabel",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
app: appArg,
|
|
22
|
+
action: z.enum(["approve", "cancel"]).optional().describe("Act on an approval (requires approval_id)"),
|
|
23
|
+
approval_id: z.string().optional().describe("The approval to act on"),
|
|
24
|
+
status: z.string().optional().describe('Filter the list (e.g. "pending"); default shows pending'),
|
|
25
|
+
},
|
|
26
|
+
async handler(args, context) {
|
|
27
|
+
if (args.action) {
|
|
28
|
+
if (!args.approval_id)
|
|
29
|
+
return fail(translate("approvals.needId"));
|
|
30
|
+
// The approval id lands in a URL path — reject anything that isn't a platform-id shape
|
|
31
|
+
// so a steered/injected value can't redirect the authenticated POST elsewhere.
|
|
32
|
+
if (!isSafeApprovalId(args.approval_id))
|
|
33
|
+
return fail(translate("approvals.invalidId"));
|
|
34
|
+
if (args.action === "approve")
|
|
35
|
+
await executeApproval(context.bff, args.approval_id);
|
|
36
|
+
else
|
|
37
|
+
await cancelApproval(context.bff, args.approval_id);
|
|
38
|
+
return reply(translate(args.action === "approve" ? "approvals.approved" : "approvals.cancelled", {
|
|
39
|
+
id: args.approval_id,
|
|
40
|
+
}), { approval_id: args.approval_id, action: args.action });
|
|
41
|
+
}
|
|
42
|
+
const resolved = await requireApp(context, args);
|
|
43
|
+
if ("out" in resolved)
|
|
44
|
+
return resolved.out;
|
|
45
|
+
const app = resolved.app;
|
|
46
|
+
if (!app.nrn)
|
|
47
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
48
|
+
const approvals = await listApprovals(context.bff, app.nrn, { status: args.status ?? "pending" });
|
|
49
|
+
const orgSlug = await context.org.organizationSlug();
|
|
50
|
+
if (approvals.length === 0) {
|
|
51
|
+
return reply(translate("approvals.none", { app: app.name }), { app: `#${app.id}`, approvals: [] });
|
|
52
|
+
}
|
|
53
|
+
const markdown = [
|
|
54
|
+
translate("approvals.title", { app: app.name, count: approvals.length }),
|
|
55
|
+
"",
|
|
56
|
+
table([
|
|
57
|
+
translate("header.id"),
|
|
58
|
+
translate("approvals.action"),
|
|
59
|
+
translate("approvals.entity"),
|
|
60
|
+
translate("header.status"),
|
|
61
|
+
translate("approvals.requestedBy"),
|
|
62
|
+
translate("header.when"),
|
|
63
|
+
], approvals.map((approval) => [
|
|
64
|
+
approval.id,
|
|
65
|
+
approval.action ?? "",
|
|
66
|
+
approval.entity ?? "",
|
|
67
|
+
`${glyph(approval.status)} ${approval.status}`,
|
|
68
|
+
approval.requestedBy ?? "",
|
|
69
|
+
ago(approval.created_at),
|
|
70
|
+
])),
|
|
71
|
+
next(translate("approvals.actHint", { id: approvals[0]?.id ?? "<id>" })) +
|
|
72
|
+
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn)),
|
|
73
|
+
].join("\n");
|
|
74
|
+
return reply(markdown, {
|
|
75
|
+
app: `#${app.id}`,
|
|
76
|
+
app_name: app.name,
|
|
77
|
+
approvals,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
});
|