@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,141 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { next, table } from "../md.js";
|
|
4
|
+
import { resolveApp } from "../np/context.js";
|
|
5
|
+
import { fail, reply } from "../tool.js";
|
|
6
|
+
export { errorMessage, fail, reply } from "../tool.js";
|
|
7
|
+
/** Pauses between mutate-then-refetch, tunable so tests don't wait. */
|
|
8
|
+
export const delays = { afterDeploy: 1200, afterTraffic: 1000, appPoll: 2000 };
|
|
9
|
+
export const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
10
|
+
export const appArg = z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Application name or "#id". Omit it to use the app linked to the current repo (git remote).');
|
|
14
|
+
export function httpsRepoUrl(url) {
|
|
15
|
+
const trimmed = url.trim().replace(/\.git$/, "");
|
|
16
|
+
const sshForm = /^git@([^:]+):(.+)$/.exec(trimmed);
|
|
17
|
+
return sshForm ? `https://${sshForm[1]}/${sshForm[2]}` : trimmed;
|
|
18
|
+
}
|
|
19
|
+
export function renderAmbiguous(matches) {
|
|
20
|
+
return [
|
|
21
|
+
translate("resolve.ambiguous"),
|
|
22
|
+
table([
|
|
23
|
+
translate("header.app"),
|
|
24
|
+
translate("header.id"),
|
|
25
|
+
translate("header.namespace"),
|
|
26
|
+
translate("header.status"),
|
|
27
|
+
], matches
|
|
28
|
+
.slice(0, 10)
|
|
29
|
+
.map((candidate) => [
|
|
30
|
+
candidate.name,
|
|
31
|
+
`#${candidate.id}`,
|
|
32
|
+
candidate.namespace ?? "",
|
|
33
|
+
candidate.status ?? "",
|
|
34
|
+
])),
|
|
35
|
+
].join("\n\n");
|
|
36
|
+
}
|
|
37
|
+
function notFoundMessage(resolution) {
|
|
38
|
+
switch (resolution.cause) {
|
|
39
|
+
case "id":
|
|
40
|
+
return translate("resolve.noId", { id: resolution.ref ?? "?" });
|
|
41
|
+
case "name":
|
|
42
|
+
return translate("resolve.noMatch", { ref: resolution.ref ?? "?" });
|
|
43
|
+
case "repo_unlinked":
|
|
44
|
+
return translate("resolve.repoUnlinked", { url: resolution.url ?? "?" });
|
|
45
|
+
case "no_input":
|
|
46
|
+
return translate("resolve.noInput");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Resolve the target app or produce the user-facing miss/ambiguity answer. */
|
|
50
|
+
export async function requireApp(context, args) {
|
|
51
|
+
const resolution = await resolveApp(context.org, args, context.repoUrl);
|
|
52
|
+
if (resolution.ok)
|
|
53
|
+
return { app: resolution.app };
|
|
54
|
+
if (resolution.reason === "ambiguous") {
|
|
55
|
+
return {
|
|
56
|
+
out: reply(renderAmbiguous(resolution.matches), { ambiguous: true, matches: resolution.matches }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { out: fail(notFoundMessage(resolution), { not_found: true }) };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Match scopes by name OR by dimensions: "dev" (name substring), "production"
|
|
63
|
+
* (dimension value substring), "environment=production" (exact key=value).
|
|
64
|
+
*/
|
|
65
|
+
export function matchScopes(scopes, wanted) {
|
|
66
|
+
const query = wanted.trim().toLowerCase();
|
|
67
|
+
const keyValue = /^([a-z0-9_-]+)\s*=\s*(.+)$/.exec(query);
|
|
68
|
+
if (keyValue?.[1] && keyValue[2] !== undefined) {
|
|
69
|
+
const [, dimensionKey, dimensionValue] = keyValue;
|
|
70
|
+
return scopes.filter((scope) => (scope.dimensions?.[dimensionKey] ?? "").toLowerCase() ===
|
|
71
|
+
dimensionValue.trim());
|
|
72
|
+
}
|
|
73
|
+
const exactNames = scopes.filter((scope) => scope.name.toLowerCase() === query);
|
|
74
|
+
if (exactNames.length)
|
|
75
|
+
return exactNames;
|
|
76
|
+
return scopes.filter((scope) => scope.name.toLowerCase().includes(query) ||
|
|
77
|
+
Object.values(scope.dimensions ?? {}).some((value) => value.toLowerCase().includes(query)));
|
|
78
|
+
}
|
|
79
|
+
export function dimsLabel(dimensions) {
|
|
80
|
+
const entries = Object.entries(dimensions ?? {});
|
|
81
|
+
return entries.length ? entries.map(([key, value]) => `${key}=${value}`).join(" · ") : "";
|
|
82
|
+
}
|
|
83
|
+
const scopeRow = (scope) => [scope.name, `#${scope.id}`, scope.status, dimsLabel(scope.dimensions)];
|
|
84
|
+
const scopeHeaders = () => [
|
|
85
|
+
translate("header.scope"),
|
|
86
|
+
translate("header.id"),
|
|
87
|
+
translate("header.status"),
|
|
88
|
+
translate("header.dimensions"),
|
|
89
|
+
];
|
|
90
|
+
/** Pick the scope to act on (writes): by name/dimension, the sole scope, or ask. */
|
|
91
|
+
export function pickScope(scopes, wanted) {
|
|
92
|
+
if (wanted) {
|
|
93
|
+
const matches = matchScopes(scopes, wanted);
|
|
94
|
+
if (matches.length === 1 && matches[0])
|
|
95
|
+
return { scope: matches[0] };
|
|
96
|
+
if (matches.length === 0) {
|
|
97
|
+
return {
|
|
98
|
+
out: fail(`${translate("scope.noMatchDetailed", { wanted })}\n\n${table(scopeHeaders(), scopes.map(scopeRow))}` +
|
|
99
|
+
next(translate("scope.createHint", { name: wanted }))),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
out: reply(`${translate("scope.which")}\n\n${table(scopeHeaders(), matches.map(scopeRow))}`),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (scopes.length === 1 && scopes[0])
|
|
107
|
+
return { scope: scopes[0] };
|
|
108
|
+
if (scopes.length === 0) {
|
|
109
|
+
return { out: fail(translate("scope.none") + next(translate("scope.noneHint"))) };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
out: reply(`${translate("scope.which")}\n\n${table(scopeHeaders(), scopes.map(scopeRow))}` +
|
|
113
|
+
next(translate("scope.deployHint"))),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Pick the scope to read from (logs/metrics): like pickScope but the ask-back carries a
|
|
118
|
+
* `choose_scope` payload the widgets render as a clickable list.
|
|
119
|
+
*/
|
|
120
|
+
export function chooseScope(scopes, wanted, options) {
|
|
121
|
+
const askData = (candidates) => ({
|
|
122
|
+
app: `#${options.app.id}`,
|
|
123
|
+
app_name: options.app.name,
|
|
124
|
+
choose_scope: candidates.map((scope) => ({ name: scope.name, status: scope.status })),
|
|
125
|
+
});
|
|
126
|
+
const ask = (candidates) => reply(`${translate("scope.perScope", { noun: translate(options.nounKey) })}\n\n${table([translate("header.scope"), translate("header.status")], candidates.map((scope) => [scope.name, scope.status]))}${next(translate("scope.readHint", { tool: options.tool, name: candidates[0]?.name ?? "" }))}`, askData(candidates));
|
|
127
|
+
if (wanted) {
|
|
128
|
+
const matches = matchScopes(scopes, wanted);
|
|
129
|
+
if (matches.length === 1 && matches[0])
|
|
130
|
+
return { scope: matches[0] };
|
|
131
|
+
if (matches.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
out: fail(translate("scope.noMatch", { wanted, names: scopes.map((scope) => scope.name).join(", ") })),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { out: ask(matches) };
|
|
137
|
+
}
|
|
138
|
+
if (scopes.length === 1 && scopes[0])
|
|
139
|
+
return { scope: scopes[0] };
|
|
140
|
+
return { out: ask(scopes) };
|
|
141
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { dashboardLink } from "../md.js";
|
|
3
|
+
import { pmap } from "../np/context.js";
|
|
4
|
+
import { getDeployment, getScope, isDeploymentTerminal, listBuilds, listReleases, listScopeDeployments, listScopes, } from "../np/journey.js";
|
|
5
|
+
import { renderRollout, renderStatus } from "../render.js";
|
|
6
|
+
import { defineTool, reply } from "../tool.js";
|
|
7
|
+
import { TOOL } from "../tool-names.js";
|
|
8
|
+
import { appArg, requireApp } from "./shared.js";
|
|
9
|
+
async function scopeViews(context, applicationId) {
|
|
10
|
+
const [scopes, releases] = await Promise.all([
|
|
11
|
+
listScopes(context.np, applicationId),
|
|
12
|
+
listReleases(context.np, applicationId, { limit: 25 }),
|
|
13
|
+
]);
|
|
14
|
+
const releasesById = new Map(releases.map((release) => [release.id, release]));
|
|
15
|
+
return pmap(scopes, async (scope) => {
|
|
16
|
+
const deployments = await listScopeDeployments(context.np, scope.id, 5);
|
|
17
|
+
const current = deployments.find((deployment) => !isDeploymentTerminal(deployment.status)) ??
|
|
18
|
+
deployments.find((deployment) => deployment.status === "finalized") ??
|
|
19
|
+
deployments[0];
|
|
20
|
+
return {
|
|
21
|
+
scope,
|
|
22
|
+
current,
|
|
23
|
+
live_release: current?.release_id ? releasesById.get(current.release_id) : undefined,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export const statusTool = defineTool({
|
|
28
|
+
name: TOOL.applicationGet,
|
|
29
|
+
title: "nullplatform status",
|
|
30
|
+
description: "THE place to start. Shows an application's full picture: scopes with what's live on each (release + traffic), latest build, latest release, and the one obvious next action. Call with no arguments inside a repo to use the linked app. Pass deployment:<id> to watch one rollout in detail.",
|
|
31
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
32
|
+
widget: "np-panel",
|
|
33
|
+
errorKey: "status.errorLabel",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
app: appArg,
|
|
36
|
+
deployment: z.number().optional().describe("Deployment id — show that rollout's live detail instead"),
|
|
37
|
+
},
|
|
38
|
+
async handler(args, context) {
|
|
39
|
+
if (args.deployment) {
|
|
40
|
+
const deployment = await getDeployment(context.np, args.deployment);
|
|
41
|
+
const [scope, orgSlug] = await Promise.all([
|
|
42
|
+
deployment.scope_id ? getScope(context.np, deployment.scope_id).catch(() => undefined) : undefined,
|
|
43
|
+
context.org.organizationSlug(),
|
|
44
|
+
]);
|
|
45
|
+
const { md, structured } = renderRollout({
|
|
46
|
+
deployment,
|
|
47
|
+
scope,
|
|
48
|
+
dashboard: dashboardLink(orgSlug, scope?.nrn),
|
|
49
|
+
});
|
|
50
|
+
return reply(md, structured);
|
|
51
|
+
}
|
|
52
|
+
const resolved = await requireApp(context, args);
|
|
53
|
+
if ("out" in resolved)
|
|
54
|
+
return resolved.out;
|
|
55
|
+
const [views, builds, releases, orgSlug] = await Promise.all([
|
|
56
|
+
scopeViews(context, resolved.app.id),
|
|
57
|
+
listBuilds(context.np, resolved.app.id, 5),
|
|
58
|
+
listReleases(context.np, resolved.app.id, { limit: 5 }),
|
|
59
|
+
context.org.organizationSlug(),
|
|
60
|
+
]);
|
|
61
|
+
const { md, structured } = renderStatus({
|
|
62
|
+
app: resolved.app,
|
|
63
|
+
views,
|
|
64
|
+
builds,
|
|
65
|
+
releases,
|
|
66
|
+
dashboard: dashboardLink(orgSlug, resolved.app.nrn),
|
|
67
|
+
});
|
|
68
|
+
return reply(md, structured);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, table } from "../md.js";
|
|
4
|
+
import { deploymentAction, getDeployment, getScope, isDeploymentTerminal, listScopeDeployments, listScopes, snapTraffic, switchTraffic, } from "../np/journey.js";
|
|
5
|
+
import { renderRollout } from "../render.js";
|
|
6
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
7
|
+
import { TOOL } from "../tool-names.js";
|
|
8
|
+
import { appArg, delays, requireApp, sleep } from "./shared.js";
|
|
9
|
+
export const trafficTool = defineTool({
|
|
10
|
+
name: TOOL.applicationDeploymentUpdate,
|
|
11
|
+
title: "Traffic / finalize / rollback",
|
|
12
|
+
description: 'Drive an in-flight rollout: move traffic to the new version (percent snaps to 1,5,10,25,50,75,90,95,99,100), finalize it (action:"finalize" — retire the old version), or roll it back (action:"rollback" — traffic returns to the old version). Finds the app\'s active deployment automatically.',
|
|
13
|
+
annotations: { destructiveHint: true, openWorldHint: true },
|
|
14
|
+
widget: "np-panel",
|
|
15
|
+
errorKey: "traffic.errorLabel",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
app: appArg,
|
|
18
|
+
deployment_id: z.number().optional().describe("Deployment id (default: the app's single active rollout)"),
|
|
19
|
+
percent: z.number().min(0).max(100).optional().describe("Desired traffic % on the new version"),
|
|
20
|
+
action: z.enum(["finalize", "rollback"]).optional(),
|
|
21
|
+
},
|
|
22
|
+
async handler(args, context) {
|
|
23
|
+
if (args.percent === undefined && !args.action) {
|
|
24
|
+
return fail(translate("traffic.sayWhat"));
|
|
25
|
+
}
|
|
26
|
+
let deploymentId = args.deployment_id;
|
|
27
|
+
let scope;
|
|
28
|
+
if (!deploymentId) {
|
|
29
|
+
const resolved = await requireApp(context, args);
|
|
30
|
+
if ("out" in resolved)
|
|
31
|
+
return resolved.out;
|
|
32
|
+
const scopes = await listScopes(context.np, resolved.app.id);
|
|
33
|
+
const activeRollouts = [];
|
|
34
|
+
for (const candidate of scopes) {
|
|
35
|
+
const deployments = await listScopeDeployments(context.np, candidate.id, 3);
|
|
36
|
+
const active = deployments.find((deployment) => !isDeploymentTerminal(deployment.status));
|
|
37
|
+
if (active)
|
|
38
|
+
activeRollouts.push({ deploymentId: active.id, scope: candidate, status: active.status });
|
|
39
|
+
}
|
|
40
|
+
if (activeRollouts.length === 0) {
|
|
41
|
+
return fail(translate("traffic.noActive", { app: resolved.app.name }));
|
|
42
|
+
}
|
|
43
|
+
if (activeRollouts.length > 1) {
|
|
44
|
+
return reply(`${translate("traffic.several")}\n\n${table([translate("header.deployment"), translate("header.scope"), translate("header.status")], activeRollouts.map((rollout) => [`#${rollout.deploymentId}`, rollout.scope.name, rollout.status]))}`);
|
|
45
|
+
}
|
|
46
|
+
const only = activeRollouts[0]; // length === 1 checked above
|
|
47
|
+
deploymentId = only.deploymentId;
|
|
48
|
+
scope = only.scope;
|
|
49
|
+
}
|
|
50
|
+
if (args.action === "finalize")
|
|
51
|
+
await deploymentAction(context.np, deploymentId, "finalize");
|
|
52
|
+
else if (args.action === "rollback")
|
|
53
|
+
await deploymentAction(context.np, deploymentId, "cancel");
|
|
54
|
+
else {
|
|
55
|
+
const snapped = snapTraffic(args.percent);
|
|
56
|
+
await switchTraffic(context.np, deploymentId, snapped);
|
|
57
|
+
}
|
|
58
|
+
await sleep(delays.afterTraffic);
|
|
59
|
+
const deployment = await getDeployment(context.np, deploymentId);
|
|
60
|
+
if (!scope && deployment.scope_id) {
|
|
61
|
+
scope = await getScope(context.np, deployment.scope_id).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
const orgSlug = await context.org.organizationSlug();
|
|
64
|
+
const snappedNote = args.percent !== undefined && snapTraffic(args.percent) !== args.percent
|
|
65
|
+
? `${translate("traffic.snapped", { requested: args.percent, snapped: snapTraffic(args.percent) })}\n\n`
|
|
66
|
+
: "";
|
|
67
|
+
const { md, structured } = renderRollout({
|
|
68
|
+
deployment,
|
|
69
|
+
scope,
|
|
70
|
+
dashboard: dashboardLink(orgSlug, scope?.nrn),
|
|
71
|
+
});
|
|
72
|
+
return reply(snappedNote + md, structured);
|
|
73
|
+
},
|
|
74
|
+
});
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { EXTENSION_ID, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY, registerAppResource, } from "@modelcontextprotocol/ext-apps/server";
|
|
5
|
+
/**
|
|
6
|
+
* MCP Apps (ext-apps) glue — hosts that support interactive widgets (claude.ai web/desktop,
|
|
7
|
+
* ChatGPT, VS Code) render a UI for a tool's results; text-only hosts keep the markdown.
|
|
8
|
+
*
|
|
9
|
+
* Widgets are React (src/widgets-react), compiled by `npm run build:widgets` into fully
|
|
10
|
+
* self-contained HTML files under widgets-dist/ — the iframe sandbox blocks all fetches,
|
|
11
|
+
* so React + the ext-apps SDK are bundled inline per widget.
|
|
12
|
+
*/
|
|
13
|
+
export { EXTENSION_ID, RESOURCE_MIME_TYPE };
|
|
14
|
+
/** Every widget this server can serve, by file name -> human title. */
|
|
15
|
+
export const WIDGETS = {
|
|
16
|
+
"np-panel": "Application panel",
|
|
17
|
+
"create-app": "Create application",
|
|
18
|
+
params: "Parameters",
|
|
19
|
+
logs: "Logs",
|
|
20
|
+
"find-apps": "Applications",
|
|
21
|
+
metrics: "Metrics",
|
|
22
|
+
};
|
|
23
|
+
/** Did this session's client negotiate the MCP Apps extension? (Knowable after initialize.) */
|
|
24
|
+
export function uiNegotiated(server) {
|
|
25
|
+
const caps = server.server.getClientCapabilities();
|
|
26
|
+
return Boolean(caps?.extensions?.[EXTENSION_ID]);
|
|
27
|
+
}
|
|
28
|
+
/** Hosts disagree on the _meta key shape — emit both the nested and the flat form. */
|
|
29
|
+
export function uiMeta(resourceUri) {
|
|
30
|
+
return { ui: { resourceUri }, [RESOURCE_URI_META_KEY]: resourceUri };
|
|
31
|
+
}
|
|
32
|
+
const distDir = join(dirname(fileURLToPath(import.meta.url)), "..", "widgets-dist");
|
|
33
|
+
const widgetCache = new Map();
|
|
34
|
+
let manifestCache;
|
|
35
|
+
function manifest() {
|
|
36
|
+
if (manifestCache)
|
|
37
|
+
return manifestCache;
|
|
38
|
+
let loaded;
|
|
39
|
+
try {
|
|
40
|
+
loaded = JSON.parse(readFileSync(join(distDir, "manifest.json"), "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
loaded = {};
|
|
44
|
+
}
|
|
45
|
+
manifestCache = loaded;
|
|
46
|
+
return loaded;
|
|
47
|
+
}
|
|
48
|
+
/** Content-hashed resource URI for a widget (stable fallback when not built yet). */
|
|
49
|
+
export function widgetUri(name) {
|
|
50
|
+
return manifest()[name] ?? `ui://widgets/${name}.html`;
|
|
51
|
+
}
|
|
52
|
+
export function loadWidget(name) {
|
|
53
|
+
const hit = widgetCache.get(name);
|
|
54
|
+
if (hit)
|
|
55
|
+
return hit;
|
|
56
|
+
let html;
|
|
57
|
+
try {
|
|
58
|
+
html = readFileSync(join(distDir, `${name}.html`), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error(`widget "${name}" not built — run \`npm run build:widgets\``);
|
|
62
|
+
}
|
|
63
|
+
widgetCache.set(name, html);
|
|
64
|
+
return html;
|
|
65
|
+
}
|
|
66
|
+
function registerWidget(server, title, file) {
|
|
67
|
+
const uri = widgetUri(file);
|
|
68
|
+
registerAppResource(server, title, uri, { _meta: { ui: { prefersBorder: true } } }, async () => ({
|
|
69
|
+
contents: [{ uri, mimeType: RESOURCE_MIME_TYPE, text: loadWidget(file) }],
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** Register widget resources (deduped). The set normally derives from the tool registry. */
|
|
73
|
+
export function registerWidgets(server, names) {
|
|
74
|
+
for (const name of new Set(names))
|
|
75
|
+
registerWidget(server, WIDGETS[name], name);
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nullplatform/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "nullplatform from your code assistant — an MCP server that replaces the dashboard for the everyday developer journey",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "nullplatform",
|
|
7
|
+
"homepage": "https://github.com/nullplatform/ai-mcp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nullplatform/ai-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/nullplatform/ai-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"nullplatform-mcp": "dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"widgets-dist",
|
|
26
|
+
"skills",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"dev": "npm run build:widgets && tsx src/index.ts",
|
|
35
|
+
"build": "rm -rf dist widgets-dist && tsc -p tsconfig.json && node scripts/build-widgets.mjs",
|
|
36
|
+
"prepack": "npm run build",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"build:widgets": "node scripts/build-widgets.mjs",
|
|
40
|
+
"pretest": "node scripts/build-widgets.mjs",
|
|
41
|
+
"lint": "biome check .",
|
|
42
|
+
"lint:fix": "biome check --write .",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p src/widgets-react/tsconfig.json --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
|
+
"zod": "^3.23.8"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@biomejs/biome": "2.4.16",
|
|
52
|
+
"@testing-library/react": "^16.3.2",
|
|
53
|
+
"@types/node": "^20.14.0",
|
|
54
|
+
"@types/react": "^19.2.17",
|
|
55
|
+
"@types/react-dom": "^19.2.3",
|
|
56
|
+
"esbuild": "^0.28.0",
|
|
57
|
+
"happy-dom": "^20.10.3",
|
|
58
|
+
"preact": "^10.29.2",
|
|
59
|
+
"react": "^19.2.7",
|
|
60
|
+
"react-dom": "^19.2.7",
|
|
61
|
+
"tsx": "^4.16.0",
|
|
62
|
+
"typescript": "^5.5.0",
|
|
63
|
+
"vitest": "^2.0.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: deploying-safely
|
|
3
|
+
description: Use when deploying, shipping, releasing or rolling out a nullplatform application — the safe rollout methodology - pre-flight checks, canary traffic steps with metric gates, finalize/rollback criteria.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploying safely on nullplatform
|
|
7
|
+
|
|
8
|
+
Deployments are blue/green: the new version comes up next to the old one, traffic moves
|
|
9
|
+
over in steps you control, and nothing is destroyed until you finalize. Rollback is cheap
|
|
10
|
+
**until** finalize; after it, going back means deploying the previous release again.
|
|
11
|
+
|
|
12
|
+
## Pre-flight (always, before any deploy)
|
|
13
|
+
|
|
14
|
+
1. Run `application_get` for the app. Confirm:
|
|
15
|
+
- No rollout already active on the target scope (one at a time per scope — the platform
|
|
16
|
+
rejects overlapping deploys).
|
|
17
|
+
- What will ship: if the latest successful build is newer than the latest release,
|
|
18
|
+
`application_deployment_create` will cut the release automatically — say so before doing it.
|
|
19
|
+
- The target scope: when several exist, pick by dimension (`scope:"environment=production"`),
|
|
20
|
+
never by guessing. Production-dimension scopes deserve a confirmation from the user.
|
|
21
|
+
2. If the change involved parameters (`application_parameter_create`), remember values only apply on this
|
|
22
|
+
deploy — mention which parameters will take effect.
|
|
23
|
+
|
|
24
|
+
## The rollout
|
|
25
|
+
|
|
26
|
+
1. `application_deployment_create` (with `version:"x.y.z"` for a specific release, or defaults for latest).
|
|
27
|
+
- Multi-asset builds: the tool auto-picks by scope type (docker-image for web pools,
|
|
28
|
+
lambda for serverless). If it asks, choose the asset matching the scope's runtime.
|
|
29
|
+
- If the result is `creating_approval` ✋: STOP — an approver must allow it
|
|
30
|
+
(dashboard → Approvals). Don't retry; watch with `application_get deployment:<id>`.
|
|
31
|
+
2. Wait for `running` (instances healthy). `waiting_for_instances` lasting more than ~5
|
|
32
|
+
minutes usually means a failing health check — check `application_log_list` before pushing traffic.
|
|
33
|
+
3. Walk traffic in steps — allowed marks are 1, 5, 10, 25, 50, 75, 90, 95, 99, 100:
|
|
34
|
+
- Low-risk/dev: 25 → 100 is fine.
|
|
35
|
+
- Production: 5 or 10 → 25 → 50 → 100, pausing 2–5 minutes per step.
|
|
36
|
+
4. **Gate every step on metrics** (`application_metric_list`, 1h window, compare against pre-deploy):
|
|
37
|
+
- `http.error_rate` rising above its pre-deploy baseline → rollback, don't rationalize.
|
|
38
|
+
- `http.response_time` degraded > ~30% sustained → hold the step, check `application_log_list`.
|
|
39
|
+
- CPU/memory near limits at partial traffic → it will not survive 100%; rollback and
|
|
40
|
+
fix sizing (scope capabilities) first.
|
|
41
|
+
5. At 100% healthy: `application_deployment_update action:"finalize"`. Tell the user finalize retires the old
|
|
42
|
+
version — past this point rollback = redeploy of the previous release.
|
|
43
|
+
|
|
44
|
+
## When in doubt
|
|
45
|
+
|
|
46
|
+
`application_deployment_update action:"rollback"` returns ALL traffic to the old version immediately and safely.
|
|
47
|
+
Prefer a needless rollback over a defended bad deploy. After rolling back, capture evidence
|
|
48
|
+
(`application_log_list`, `application_metric_list`) before retrying.
|
|
49
|
+
|
|
50
|
+
## First-ever deploy on a scope
|
|
51
|
+
|
|
52
|
+
There is no old version to fall back to: traffic semantics don't apply, the deployment
|
|
53
|
+
finalizes on its own once instances are healthy, and "rollback" means cancel. Verify with
|
|
54
|
+
the scope's domain URL from `application_get` once finalized.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incident-response
|
|
3
|
+
description: Use when a nullplatform application is failing, degraded, erroring, slow or down — the triage runbook - establish state, read the right signals, mitigate first (rollback), then diagnose.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Incident response on nullplatform
|
|
7
|
+
|
|
8
|
+
Mitigate first, diagnose second. The fastest mitigation is almost always returning traffic
|
|
9
|
+
to the last known-good version.
|
|
10
|
+
|
|
11
|
+
## 1. Establish state (one call)
|
|
12
|
+
|
|
13
|
+
`application_get` for the app. Read it as a timeline:
|
|
14
|
+
- Is a rollout active or recent? An incident that started with a deploy is that deploy
|
|
15
|
+
until proven otherwise.
|
|
16
|
+
- Which release is live on the affected scope, and since when ("When" column)?
|
|
17
|
+
- Any scope in `failed` / approval-blocked state?
|
|
18
|
+
|
|
19
|
+
## 2. Mitigate
|
|
20
|
+
|
|
21
|
+
- Active rollout suspected → `application_deployment_update action:"rollback"` immediately. It is safe, fast,
|
|
22
|
+
and reversible (you can redeploy). Do not wait for full diagnosis.
|
|
23
|
+
- Already finalized → redeploy the previous version: `application_deployment_create version:"<previous semver>"`
|
|
24
|
+
(the Releases line in `application_get` lists them in order), then walk traffic with the
|
|
25
|
+
deploying-safely methodology — faster this time, but still metric-gated.
|
|
26
|
+
- No deploy correlation → likely capacity or dependency: check metrics first (below).
|
|
27
|
+
|
|
28
|
+
## 3. Read the signals
|
|
29
|
+
|
|
30
|
+
- `application_metric_list` (start 1h, widen to 24h to find when it broke):
|
|
31
|
+
- error rate up + response time up → app-level failure (recent code/config/dependency).
|
|
32
|
+
- response time up, errors flat → saturation: CPU/memory near 100% means the scope is
|
|
33
|
+
undersized — scaling/capability change, not rollback, is the fix.
|
|
34
|
+
- throughput cliff to ~0 → upstream/routing problem; the app may be healthy.
|
|
35
|
+
- `application_log_list` (the affected scope; filter for `ERROR`, `FATAL`, stack traces):
|
|
36
|
+
- note the FIRST error timestamp and correlate with the deploy time from `application_get`.
|
|
37
|
+
- `application_parameter_list` — config is a top cause: was a parameter changed before the last deploy?
|
|
38
|
+
Secret values are masked, but names + the deploy timing tell the story.
|
|
39
|
+
|
|
40
|
+
## 4. Close the loop
|
|
41
|
+
|
|
42
|
+
After mitigation, confirm with `application_metric_list` (error rate back to baseline) AND the scope's
|
|
43
|
+
domain URL responding. Summarize for the user: what broke, when, the mitigation applied,
|
|
44
|
+
and the evidence — and leave the bad release identified so it isn't redeployed by the
|
|
45
|
+
"latest" defaults.
|
|
46
|
+
|
|
47
|
+
## Don'ts
|
|
48
|
+
|
|
49
|
+
- Don't deploy a "quick fix" forward while the incident is open — roll back first.
|
|
50
|
+
- Don't finalize anything during an incident.
|
|
51
|
+
- Don't restart/recreate scopes as a first move; that destroys the evidence and rarely
|
|
52
|
+
fixes the cause.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: platform-conventions
|
|
3
|
+
description: Use when interpreting nullplatform entities — applications, builds, releases, scopes, deployments, dimensions, parameters - what each is, how they chain, and the platform's semantics and gotchas.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# nullplatform conventions
|
|
7
|
+
|
|
8
|
+
## The entity chain
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
application ← build (CI) ← asset(s) ← release (semver) → deployment → scope
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- **Builds** come only from CI (push a commit); they cannot be created here.
|
|
15
|
+
- A **release** is an immutable semver pointer to a build. Deploys ship releases, never
|
|
16
|
+
builds directly. Latest build ≠ latest release until someone cuts it.
|
|
17
|
+
- A **scope** is a deploy target (real infrastructure: k8s pool, EC2, serverless…).
|
|
18
|
+
Its `domain` is the live URL. Creating one provisions infra — treat as a real action.
|
|
19
|
+
- A **deployment** is one release landing on one scope, with blue/green traffic control.
|
|
20
|
+
|
|
21
|
+
## Dimensions
|
|
22
|
+
|
|
23
|
+
Scopes carry org-defined dimensions like `environment` and `country`
|
|
24
|
+
(e.g. `environment=production · country=argentina`). Conventions:
|
|
25
|
+
- "deploy to production" means the scope whose `environment=production` — match by
|
|
26
|
+
dimension, not by the scope's display name.
|
|
27
|
+
- Dimension *definitions* are admin-gated; infer valid shapes from sibling scopes.
|
|
28
|
+
- New scopes in dimension-using orgs must carry dimensions (tools borrow a sibling's
|
|
29
|
+
shape by default).
|
|
30
|
+
|
|
31
|
+
## Versioning
|
|
32
|
+
|
|
33
|
+
- Releases use semver, optionally `v`-prefixed; orgs may enforce a pattern.
|
|
34
|
+
- Auto-cut releases bump the patch. "Deploy 1.4.0" targets the EXISTING 1.4.0 release —
|
|
35
|
+
that's also how you roll back after a finalize.
|
|
36
|
+
|
|
37
|
+
## Parameters
|
|
38
|
+
|
|
39
|
+
- Env vars or files, app-NRN scoped, optionally secret (values never readable again).
|
|
40
|
+
- Changes take effect ONLY on the next deploy of each scope — saying "parameter updated"
|
|
41
|
+
without mentioning the pending redeploy is misleading.
|
|
42
|
+
|
|
43
|
+
## Traffic & lifecycle semantics
|
|
44
|
+
|
|
45
|
+
- Traffic percentages snap to: 1, 5, 10, 25, 50, 75, 90, 95, 99, 100.
|
|
46
|
+
- Deployment statuses: `creating → waiting_for_instances → running (traffic phase) →
|
|
47
|
+
finalizing → finalized`; terminal also includes `failed`, `cancelled`, `rolled_back`,
|
|
48
|
+
`creating_approval_denied`. `creating_approval` means a human approval gate.
|
|
49
|
+
- One active rollout per scope.
|
|
50
|
+
|
|
51
|
+
## Gotchas worth knowing
|
|
52
|
+
|
|
53
|
+
- Multi-asset builds (e.g. docker + lambda) need an asset choice per scope; the platform's
|
|
54
|
+
error for a wrong/missing choice misleadingly says "scope and release belong to
|
|
55
|
+
different applications".
|
|
56
|
+
- Logs and metrics are per-scope, never app-wide.
|
|
57
|
+
- Approvals can gate deploys org-wide; a "stuck" deploy in `creating_approval` is waiting
|
|
58
|
+
for a human, not broken.
|
|
59
|
+
- Every entity has an NRN and a dashboard deep link (`…app.nullplatform.io/nrn/<nrn>`) —
|
|
60
|
+
offer it when the user needs surfaces this integration doesn't cover (approvals UI,
|
|
61
|
+
scope capability editing, team management).
|