@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -0
  3. package/dist/config.js +26 -0
  4. package/dist/git.js +27 -0
  5. package/dist/http.js +330 -0
  6. package/dist/i18n.js +595 -0
  7. package/dist/index.js +72 -0
  8. package/dist/md.js +110 -0
  9. package/dist/np/auth.js +130 -0
  10. package/dist/np/client.js +72 -0
  11. package/dist/np/context.js +201 -0
  12. package/dist/np/journey.js +403 -0
  13. package/dist/prompts.js +64 -0
  14. package/dist/render.js +236 -0
  15. package/dist/server.js +91 -0
  16. package/dist/skills.js +84 -0
  17. package/dist/surfaces/developer.js +29 -0
  18. package/dist/surfaces/index.js +17 -0
  19. package/dist/surfaces/surface.js +1 -0
  20. package/dist/tool-names.js +25 -0
  21. package/dist/tool.js +92 -0
  22. package/dist/tools/approvals.js +80 -0
  23. package/dist/tools/builds.js +94 -0
  24. package/dist/tools/create-app.js +187 -0
  25. package/dist/tools/create-release.js +52 -0
  26. package/dist/tools/create-scope.js +82 -0
  27. package/dist/tools/deploy.js +178 -0
  28. package/dist/tools/find-apps.js +36 -0
  29. package/dist/tools/index.js +39 -0
  30. package/dist/tools/logs.js +83 -0
  31. package/dist/tools/metrics.js +83 -0
  32. package/dist/tools/overview.js +110 -0
  33. package/dist/tools/params.js +58 -0
  34. package/dist/tools/playbook.js +39 -0
  35. package/dist/tools/services.js +58 -0
  36. package/dist/tools/set-params.js +58 -0
  37. package/dist/tools/shared.js +141 -0
  38. package/dist/tools/status.js +70 -0
  39. package/dist/tools/traffic.js +74 -0
  40. package/dist/ui.js +76 -0
  41. package/package.json +65 -0
  42. package/skills/deploying-safely/SKILL.md +54 -0
  43. package/skills/incident-response/SKILL.md +52 -0
  44. package/skills/platform-conventions/SKILL.md +61 -0
  45. package/widgets-dist/create-app.html +830 -0
  46. package/widgets-dist/find-apps.html +831 -0
  47. package/widgets-dist/logs.html +830 -0
  48. package/widgets-dist/manifest.json +8 -0
  49. package/widgets-dist/metrics.html +829 -0
  50. package/widgets-dist/np-panel.html +831 -0
  51. package/widgets-dist/params.html +829 -0
@@ -0,0 +1,403 @@
1
+ /** Traffic percentages the platform accepts (same marks the dashboard slider snaps to). */
2
+ export const TRAFFIC_MARKS = [0, 1, 5, 10, 25, 50, 75, 90, 95, 99, 100];
3
+ export const snapTraffic = (percent) => TRAFFIC_MARKS.reduce((closest, mark) => (Math.abs(mark - percent) < Math.abs(closest - percent) ? mark : closest), 0);
4
+ const TERMINAL = new Set([
5
+ "finalized",
6
+ "cancelled",
7
+ "rolled_back",
8
+ "failed",
9
+ "deleted",
10
+ "creating_approval_denied",
11
+ ]);
12
+ export const isDeploymentTerminal = (status) => !!status && TERMINAL.has(status);
13
+ export function bumpSemver(semver) {
14
+ const parts = /^v?(\d+)\.(\d+)\.(\d+)/.exec(semver ?? "");
15
+ return parts ? `${parts[1]}.${parts[2]}.${Number(parts[3]) + 1}` : "0.0.1";
16
+ }
17
+ export async function listBuilds(np, applicationId, limit = 10) {
18
+ const page = await np
19
+ .get("/build", { application_id: applicationId, sort: "created_at:desc", limit })
20
+ .catch(() => ({ results: [] }));
21
+ return (page.results ?? []).map((build) => ({
22
+ id: build.id,
23
+ status: build.status,
24
+ branch: build.branch,
25
+ commit: String(build.commit?.id ?? build.commit_id ?? "") || undefined,
26
+ created_at: build.created_at,
27
+ }));
28
+ }
29
+ function mapRelease(raw) {
30
+ return {
31
+ id: raw.id,
32
+ semver: raw.semver,
33
+ status: raw.status,
34
+ build_id: raw.build_id,
35
+ created_at: raw.created_at,
36
+ };
37
+ }
38
+ export async function listReleases(np, applicationId, options = {}) {
39
+ const query = {
40
+ application_id: applicationId,
41
+ sort: "created_at:desc",
42
+ limit: options.limit ?? 10,
43
+ };
44
+ if (options.status)
45
+ query.status = options.status;
46
+ const page = await np
47
+ .get("/release", query)
48
+ .catch(() => ({ results: [] }));
49
+ return (page.results ?? []).map(mapRelease);
50
+ }
51
+ export async function getRelease(np, releaseId) {
52
+ return mapRelease(await np.get(`/release/${releaseId}`));
53
+ }
54
+ export async function createRelease(np, args) {
55
+ let semver = args.semver;
56
+ if (!semver) {
57
+ const latest = await listReleases(np, args.application_id, { limit: 1 });
58
+ semver = bumpSemver(latest[0]?.semver);
59
+ }
60
+ const created = await np.post("/release", {
61
+ application_id: args.application_id,
62
+ build_id: args.build_id,
63
+ semver,
64
+ status: "active",
65
+ });
66
+ return {
67
+ id: created.id,
68
+ semver: created.semver ?? semver,
69
+ status: created.status ?? "active",
70
+ build_id: args.build_id,
71
+ };
72
+ }
73
+ /**
74
+ * Convergent release: agents retry, so a re-issued "cut a release from build N" must not
75
+ * mint a second one. Returns the newest active release already built from `build_id`
76
+ * (any when no specific semver is asked), else undefined so the caller cuts a new one.
77
+ */
78
+ export async function findActiveReleaseForBuild(np, applicationId, buildId, semver) {
79
+ const releases = await listReleases(np, applicationId, { status: "active", limit: 200 });
80
+ const sameSemver = (left, right) => left.replace(/^v/, "") === right.replace(/^v/, "");
81
+ return releases.find((release) => release.build_id === buildId && (!semver || sameSemver(release.semver, semver)));
82
+ }
83
+ export async function listAssets(np, buildId) {
84
+ const page = await np
85
+ .get("/asset", { build_id: buildId, limit: 30 })
86
+ .catch(() => ({ results: [] }));
87
+ return (page.results ?? []).map((asset) => ({
88
+ id: asset.id,
89
+ name: asset.name,
90
+ type: asset.type,
91
+ platform: asset.platform,
92
+ }));
93
+ }
94
+ /**
95
+ * Choose the asset a deployment should ship onto a scope. Multi-asset builds REQUIRE an
96
+ * explicit asset_name — without it the platform rejects the deploy with a misleading
97
+ * "scope and release belong to different applications" (verified live).
98
+ */
99
+ export function pickAsset(assets, scopeType) {
100
+ if (assets.length === 0)
101
+ return { name: undefined };
102
+ if (assets.length === 1)
103
+ return { name: assets[0]?.name };
104
+ const wantLambda = /serverless|lambda/i.test(scopeType ?? "");
105
+ const matching = assets.filter((asset) => wantLambda ? /lambda/i.test(asset.type) : /docker|image|container/i.test(asset.type));
106
+ if (matching.length === 1)
107
+ return { name: matching[0]?.name };
108
+ return { ambiguous: assets };
109
+ }
110
+ function mapScope(raw) {
111
+ return {
112
+ id: raw.id,
113
+ name: raw.name,
114
+ status: raw.status,
115
+ type: raw.type,
116
+ nrn: raw.nrn,
117
+ domain: raw.domain,
118
+ application_id: raw.application_id,
119
+ dimensions: raw.dimensions,
120
+ };
121
+ }
122
+ export async function listScopes(np, applicationId) {
123
+ const page = await np
124
+ .get("/scope", { application_id: applicationId, limit: 100 })
125
+ .catch(() => ({ results: [] }));
126
+ return (page.results ?? []).filter((scope) => scope.status !== "deleted").map(mapScope);
127
+ }
128
+ export async function getScope(np, scopeId) {
129
+ return mapScope(await np.get(`/scope/${scopeId}`));
130
+ }
131
+ export async function listScopeTypes(np, nrn) {
132
+ const page = await np
133
+ .get("/scope_type", { nrn, status: "active", include: "capabilities,available" })
134
+ .catch(() => ({ results: [] }));
135
+ return (page.results ?? []).map((scopeType) => ({
136
+ id: scopeType.id,
137
+ name: scopeType.name,
138
+ type: scopeType.type,
139
+ provider: scopeType.provider,
140
+ }));
141
+ }
142
+ export async function createScope(np, args) {
143
+ const body = {
144
+ name: args.name,
145
+ type: args.type,
146
+ application_id: args.application_id,
147
+ requested_spec: args.requested_spec ?? {},
148
+ };
149
+ if (args.provider)
150
+ body.provider = args.provider;
151
+ if (args.dimensions)
152
+ body.dimensions = args.dimensions;
153
+ const created = await np.post("/scope", body);
154
+ return { id: created.id, name: created.name, status: created.status, type: created.type, nrn: created.nrn };
155
+ }
156
+ function mapDeployment(raw) {
157
+ const strategyData = raw.strategy_data ?? {};
158
+ return {
159
+ id: raw.id,
160
+ status: raw.status,
161
+ strategy: raw.strategy,
162
+ scope_id: raw.scope_id,
163
+ release_id: raw.release_id,
164
+ created_at: raw.created_at,
165
+ strategy_data: {
166
+ ...strategyData,
167
+ switchedTraffic: strategyData.switchedTraffic ?? strategyData.switched_traffic,
168
+ desiredSwitchedTraffic: strategyData.desiredSwitchedTraffic ?? strategyData.desired_switched_traffic,
169
+ },
170
+ messages: raw.messages ?? [],
171
+ };
172
+ }
173
+ export async function createDeployment(np, args) {
174
+ const body = { scope_id: args.scope_id, release_id: args.release_id };
175
+ if (args.asset_name)
176
+ body.asset_name = args.asset_name;
177
+ return mapDeployment(await np.post("/deployment", body));
178
+ }
179
+ export async function getDeployment(np, deploymentId) {
180
+ return mapDeployment(await np.get(`/deployment/${deploymentId}`));
181
+ }
182
+ /** Latest deployments of a scope, newest first. */
183
+ export async function listScopeDeployments(np, scopeId, limit = 3) {
184
+ const page = await np
185
+ .get("/deployment", { scope_id: scopeId, sort: "created_at:desc", limit })
186
+ .catch(() => ({ results: [] }));
187
+ return (page.results ?? []).map(mapDeployment);
188
+ }
189
+ /**
190
+ * Traffic switch — the public API takes desiredSwitchedTraffic (camelCase INSIDE the
191
+ * snake_case strategy_data envelope; verified live) and moves traffic toward it.
192
+ */
193
+ export async function switchTraffic(np, deploymentId, percent) {
194
+ await np.patch(`/deployment/${deploymentId}`, {
195
+ strategy_data: { desiredSwitchedTraffic: snapTraffic(percent) },
196
+ });
197
+ }
198
+ export async function deploymentAction(np, deploymentId, action) {
199
+ await np.patch(`/deployment/${deploymentId}`, {
200
+ status: action === "finalize" ? "finalizing" : "cancelling",
201
+ });
202
+ }
203
+ /**
204
+ * Upsert parameters. Agents retry, and re-running "set DATABASE_URL" must not accrete a
205
+ * duplicate definition — so reuse the existing definition (matched by variable/name at this
206
+ * NRN) and just add a value; only POST a new definition when none exists. Reports how many
207
+ * were created vs updated.
208
+ */
209
+ export async function setParameters(np, nrn, params) {
210
+ const existing = (await listParameters(np, nrn)) ?? [];
211
+ const byKey = new Map(existing.map((parameter) => [(parameter.variable ?? parameter.name).toLowerCase(), parameter]));
212
+ let created = 0;
213
+ let updated = 0;
214
+ for (const param of params) {
215
+ const match = byKey.get(param.name.toLowerCase());
216
+ let definitionId;
217
+ if (match) {
218
+ definitionId = match.id;
219
+ updated++;
220
+ }
221
+ else {
222
+ const definition = await np.post("/parameter", {
223
+ nrn,
224
+ name: param.name,
225
+ type: (param.type ?? "ENVIRONMENT").toLowerCase(), // API accepts lowercase only ("environment"|"file")
226
+ secret: param.secret ?? false,
227
+ variable: param.name,
228
+ });
229
+ definitionId = definition.id;
230
+ created++;
231
+ }
232
+ await np.post(`/parameter/${definitionId}/values`, { values: [{ nrn, value: param.value }] });
233
+ }
234
+ return { created, updated };
235
+ }
236
+ /** Read parameters — NRN-scoped on the public API; returns undefined when unavailable. */
237
+ export async function listParameters(np, nrn) {
238
+ try {
239
+ const page = await np.get("/parameter", { nrn, limit: 100 });
240
+ return (page.results ?? []).map((parameter) => ({
241
+ id: parameter.id,
242
+ name: parameter.name,
243
+ variable: parameter.variable,
244
+ type: parameter.type,
245
+ secret: !!parameter.secret,
246
+ values: (parameter.values ?? []).map((entry) => ({
247
+ value: parameter.secret ? "•••" : entry.value,
248
+ nrn: entry.nrn,
249
+ })),
250
+ }));
251
+ }
252
+ catch {
253
+ return undefined;
254
+ }
255
+ }
256
+ // ---- metrics (served by the dashboard BFF — the public gateway has no metrics surface) ----
257
+ export const GOLDEN_METRICS = [
258
+ { id: "http.rpm", label: "Throughput", unit: "rpm" },
259
+ { id: "http.response_time", label: "Response time", unit: "ms" },
260
+ { id: "http.error_rate", label: "Error rate", unit: "%" },
261
+ { id: "system.cpu_usage_percentage", label: "CPU", unit: "%" },
262
+ { id: "system.memory_usage_percentage", label: "Memory", unit: "%" },
263
+ ];
264
+ const WINDOW_HOURS = { "1h": 1, "3h": 3, "24h": 24, "7d": 168 };
265
+ export function windowHours(window) {
266
+ return WINDOW_HOURS[window ?? "3h"] ?? 3;
267
+ }
268
+ /** One metric's series for a scope. start_time must be the Z-suffixed ISO form. */
269
+ export async function readMetric(bff, args) {
270
+ const now = args.now ?? new Date();
271
+ const start = new Date(now.getTime() - args.hours * 3_600_000);
272
+ const zSuffixedIso = (date) => date.toISOString().replace(/\.\d{3}Z$/, ".000Z");
273
+ const query = {
274
+ scope_id: args.scope_id,
275
+ start_time: zSuffixedIso(start),
276
+ };
277
+ if (args.v2)
278
+ query.metrics_version = "v2";
279
+ const response = await bff.get(`/v1/application/${args.application_id}/metric/${args.metric}`, query);
280
+ const data = response?.results?.[0]?.data ?? [];
281
+ return data
282
+ .map((point) => ({ t: point.timestamp, v: point.value }))
283
+ .sort((left, right) => left.t.localeCompare(right.t));
284
+ }
285
+ export async function readGoldenMetrics(bff, args) {
286
+ const v2 = /k8s|serverless/i.test(args.scope_type ?? "");
287
+ const series = await Promise.all(GOLDEN_METRICS.map(async (metric) => {
288
+ const points = await readMetric(bff, { ...args, metric: metric.id, v2 }).catch(() => []);
289
+ const values = points.map((point) => point.v);
290
+ return {
291
+ id: metric.id,
292
+ label: metric.label,
293
+ unit: metric.unit,
294
+ points,
295
+ last: values.length ? values[values.length - 1] : undefined,
296
+ avg: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : undefined,
297
+ max: values.length ? Math.max(...values) : undefined,
298
+ };
299
+ }));
300
+ return series;
301
+ }
302
+ // ---- logs ----
303
+ /** Logs are served per scope — the platform rejects unscoped reads. */
304
+ export async function readLogs(np, args) {
305
+ const page = await np.get(`/application/${args.application_id}/log`, {
306
+ type: "application",
307
+ scope: args.scope_id,
308
+ next_page_token: args.next_page_token,
309
+ start_time: args.start_time,
310
+ end_time: args.end_time,
311
+ });
312
+ return {
313
+ results: page.results ?? [],
314
+ next_page_token: page.paging?.next_page_token ?? page.paging?.nextPageToken,
315
+ };
316
+ }
317
+ export async function createApplication(np, body) {
318
+ return np.post("/application", body);
319
+ }
320
+ /**
321
+ * Scaffolding templates available for a new repository (language/runtime starters that ship a
322
+ * working CI pipeline). `target_nrn` scopes which org/account-owned templates are visible on
323
+ * top of the nullplatform-global set.
324
+ */
325
+ export async function listTemplates(np, targetNrn) {
326
+ const query = { status: "active", limit: 200 };
327
+ if (targetNrn)
328
+ query.target_nrn = targetNrn;
329
+ const page = await np.get("/template", query);
330
+ return (page.results ?? []).map((template) => ({
331
+ id: template.id,
332
+ name: template.name,
333
+ tags: template.tags,
334
+ organization: template.organization ?? null,
335
+ account: template.account ?? null,
336
+ }));
337
+ }
338
+ export async function getApplication(np, applicationId) {
339
+ return np.get(`/application/${applicationId}`);
340
+ }
341
+ function mapApproval(raw) {
342
+ return {
343
+ id: raw.id,
344
+ status: raw.status ?? "pending",
345
+ action: raw.entity_action,
346
+ entity: raw.entity_name,
347
+ requestedBy: raw.owner_info?.name,
348
+ created_at: raw.created_at,
349
+ };
350
+ }
351
+ /** Approvals gating an entity (e.g. a deployment) under an NRN. BFF contract, /v1. */
352
+ export async function listApprovals(bff, nrn, options = {}) {
353
+ const query = { nrn };
354
+ if (options.status)
355
+ query.status = options.status;
356
+ const page = await bff
357
+ .get("/v1/approval/list", query)
358
+ .catch(() => ({ results: [] }));
359
+ return (page.results ?? []).map(mapApproval);
360
+ }
361
+ /**
362
+ * Approval ids are the one string we interpolate into a URL path (every other path
363
+ * segment is a number). A model can be steered — e.g. by prompt-injected content — into
364
+ * passing `../service/svc-1/action`, which would redirect the authenticated POST to an
365
+ * unintended endpoint. The NpClient encodes query params but not path segments, so guard
366
+ * here: only platform-id shapes (alphanumeric, dash, underscore) reach the wire.
367
+ */
368
+ export function isSafeApprovalId(approvalId) {
369
+ return /^[A-Za-z0-9_-]{1,128}$/.test(approvalId);
370
+ }
371
+ function assertSafeApprovalId(approvalId) {
372
+ if (!isSafeApprovalId(approvalId))
373
+ throw new Error(`invalid approval id: ${JSON.stringify(approvalId)}`);
374
+ }
375
+ /** Execute/approve a decided approval so its gated action proceeds (the dashboard's execute). */
376
+ export async function executeApproval(bff, approvalId) {
377
+ assertSafeApprovalId(approvalId);
378
+ await bff.post(`/v1/approval/${approvalId}`);
379
+ }
380
+ /** Cancel an approval request. */
381
+ export async function cancelApproval(bff, approvalId) {
382
+ assertSafeApprovalId(approvalId);
383
+ await bff.post(`/v1/approval/${approvalId}/cancel`);
384
+ }
385
+ /** Service instances (dependencies — DBs, queues…) attached under an entity NRN. */
386
+ export async function listServices(bff, nrn) {
387
+ const page = await bff
388
+ .get("/v1/service", { nrn, type: "dependency" })
389
+ .catch(() => ({ results: [] }));
390
+ return (page.results ?? []).map((service) => ({
391
+ id: service.id,
392
+ name: service.name ?? service.specification_name ?? service.id,
393
+ status: service.status,
394
+ specification: service.specification_name,
395
+ }));
396
+ }
397
+ /** The catalog of provisionable dependency types available to an entity. */
398
+ export async function listServiceSpecifications(bff, nrn) {
399
+ const page = await bff
400
+ .get("/v1/service_specification", { nrn, type: "dependency" })
401
+ .catch(() => ({ results: [] }));
402
+ return (page.results ?? []).map((spec) => ({ id: spec.id, name: spec.name ?? spec.id }));
403
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ import { matchLocale, translate, withLocale } from "./i18n.js";
3
+ const appPromptArg = z.string().optional().describe("Application (default: current repo)");
4
+ const appOrRepo = (app) => app ?? translate("prompt.thisRepoApp");
5
+ const prompts = [
6
+ {
7
+ name: "ship",
8
+ title: "Ship the latest code",
9
+ description: "Deploy the latest build and walk traffic to 100% safely",
10
+ argsSchema: {
11
+ app: appPromptArg,
12
+ scope: z.string().optional().describe("Scope to ship to (default: the app's only scope)"),
13
+ },
14
+ render: ({ app, scope }) => translate("prompt.ship", {
15
+ app: appOrRepo(app),
16
+ scope: scope ? translate("prompt.ship.toScope", { scope }) : "",
17
+ }),
18
+ },
19
+ {
20
+ name: "setup",
21
+ title: "Connect this repo",
22
+ description: "Link the current repository to a nullplatform application",
23
+ argsSchema: {},
24
+ render: () => translate("prompt.setup"),
25
+ },
26
+ {
27
+ name: "health",
28
+ title: "Health check",
29
+ description: "One-shot digest: status + golden metrics + recent logs",
30
+ argsSchema: { app: appPromptArg },
31
+ render: ({ app }) => translate("prompt.health", { app: appOrRepo(app) }),
32
+ },
33
+ {
34
+ name: "rollback",
35
+ title: "Roll back now",
36
+ description: "Return traffic to the previous version immediately",
37
+ argsSchema: { app: appPromptArg },
38
+ render: ({ app }) => translate("prompt.rollback", { app: appOrRepo(app) }),
39
+ },
40
+ ];
41
+ const promptLanguageArg = z
42
+ .string()
43
+ .optional()
44
+ .describe('Language of the user\'s conversation, as an ISO code (e.g. "en", "es")');
45
+ export function registerPrompts(server) {
46
+ for (const prompt of prompts) {
47
+ server.registerPrompt(prompt.name, {
48
+ title: prompt.title,
49
+ description: prompt.description,
50
+ argsSchema: { ...prompt.argsSchema, language: promptLanguageArg },
51
+ }, (args) => {
52
+ const requested = matchLocale(args.language);
53
+ const text = requested ? withLocale(requested, () => prompt.render(args)) : prompt.render(args);
54
+ return {
55
+ messages: [
56
+ {
57
+ role: "user",
58
+ content: { type: "text", text },
59
+ },
60
+ ],
61
+ };
62
+ });
63
+ }
64
+ }