@nullplatform/mcp 0.1.4 → 0.1.5

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.
@@ -16,6 +16,7 @@ export const approvalsTool = defineTool({
16
16
  title: "Approvals",
17
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
18
  annotations: { destructiveHint: false, openWorldHint: true },
19
+ widget: "approvals",
19
20
  errorKey: "approvals.errorLabel",
20
21
  inputSchema: {
21
22
  app: appArg,
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { translate } from "../i18n.js";
3
3
  import { ago, glyph, next, shortCommit } from "../md.js";
4
- import { createRelease, findActiveReleaseForBuild, listBuilds } from "../np/journey.js";
4
+ import { createRelease, findActiveReleaseForBuild, listBuilds, listReleases } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
7
  import { appArg, requireApp } from "./shared.js";
@@ -9,7 +9,9 @@ export const createReleaseTool = defineTool({
9
9
  name: TOOL.applicationReleaseCreate,
10
10
  title: "Create release",
11
11
  description: "Cut an active release from a build (default: the latest successful build; semver auto-bumps the patch). Usually you can just call deploy, which does this for you.",
12
- annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
12
+ // Convergent under retries (reuses the active release-for-build), so idempotent.
13
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
14
+ widget: "releases",
13
15
  errorKey: "createRelease.errorLabel",
14
16
  inputSchema: {
15
17
  app: appArg,
@@ -40,13 +42,17 @@ export const createReleaseTool = defineTool({
40
42
  // (matching the requested semver, if any), return it instead of minting a duplicate.
41
43
  const existing = await findActiveReleaseForBuild(context.np, app.id, buildId, args.semver);
42
44
  if (existing) {
43
- return reply(`${glyph("active")} ${translate("createRelease.exists", { semver: existing.semver, build: buildId })}${next(translate("createRelease.deployHint", { id: existing.id }))}`, { release: existing, reused: true });
45
+ const releases = await listReleases(context.np, app.id, { limit: 25 }).catch(() => [existing]);
46
+ return reply(`${glyph("active")} ${translate("createRelease.exists", { semver: existing.semver, build: buildId })}${next(translate("createRelease.deployHint", { id: existing.id }))}`, { release: existing, reused: true, app: `#${app.id}`, app_name: app.name, releases });
44
47
  }
45
48
  const release = await createRelease(context.np, {
46
49
  application_id: app.id,
47
50
  build_id: buildId,
48
51
  semver: args.semver,
49
52
  });
50
- return reply(`${glyph("active")} ${translate("createRelease.done", { semver: release.semver, from: buildNote })}${next(translate("createRelease.deployHint", { id: release.id }))}`, { release, reused: false });
53
+ // Mirror the releases widget's shape so the SAME panel renders the new release with its
54
+ // Deploy action (read-after-write); the markdown stays the plain confirmation.
55
+ const releases = await listReleases(context.np, app.id, { limit: 25 }).catch(() => [release]);
56
+ return reply(`${glyph("active")} ${translate("createRelease.done", { semver: release.semver, from: buildNote })}${next(translate("createRelease.deployHint", { id: release.id }))}`, { release, reused: false, app: `#${app.id}`, app_name: app.name, releases });
51
57
  },
52
58
  });
@@ -5,11 +5,14 @@ import { createScope, listScopes, listScopeTypes } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
7
  import { appArg, dimsLabel, requireApp } from "./shared.js";
8
+ import { buildAppStatus } from "./status.js";
8
9
  export const createScopeTool = defineTool({
9
10
  name: TOOL.applicationScopeCreate,
10
11
  title: "Create scope",
11
12
  description: "Create a scope (a deploy target, e.g. dev/staging/main) for an application. This provisions real infrastructure asynchronously. If the org has several scope types and none is given, it lists them to pick from.",
12
- annotations: { destructiveHint: false, openWorldHint: true },
13
+ // Convergent under retries (reuses the scope-by-name), so idempotent.
14
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
15
+ widget: "np-panel",
13
16
  errorKey: "createScope.errorLabel",
14
17
  inputSchema: {
15
18
  app: appArg,
@@ -31,10 +34,13 @@ export const createScopeTool = defineTool({
31
34
  const siblings = await listScopes(context.np, app.id);
32
35
  const existing = siblings.find((scope) => scope.name.toLowerCase() === args.name.toLowerCase());
33
36
  if (existing) {
34
- const orgSlug = await context.org.organizationSlug();
37
+ const [orgSlug, status] = await Promise.all([
38
+ context.org.organizationSlug(),
39
+ buildAppStatus(context, app),
40
+ ]);
35
41
  return reply(translate("createScope.exists", { name: existing.name, id: existing.id, status: existing.status }) +
36
42
  next(translate("createScope.provisioningHint", { name: existing.name })) +
37
- linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), { scope: existing, reused: true });
43
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), { ...status.structured, scope: existing, reused: true });
38
44
  }
39
45
  let type = args.type;
40
46
  let provider = args.provider;
@@ -46,11 +52,14 @@ export const createScopeTool = defineTool({
46
52
  provider = provider ?? onlyType.provider ?? undefined;
47
53
  }
48
54
  else if (scopeTypes.length > 1) {
55
+ // np-panel's create-scope form picks a type from scope_types; the overview rides along
56
+ // so a fresh app (no scopes yet) renders that form instead of an empty panel.
57
+ const { structured } = await buildAppStatus(context, app);
49
58
  return reply(`${translate("createScope.whichType", { name: args.name })}\n\n${table([translate("header.type"), translate("header.name"), translate("header.provider")], scopeTypes.map((scopeType) => [
50
59
  scopeType.type ?? "",
51
60
  scopeType.name ?? "",
52
61
  scopeType.provider ?? "",
53
- ]))}${next(translate("createScope.typeHint", { name: args.name }))}`, { scope_types: scopeTypes });
62
+ ]))}${next(translate("createScope.typeHint", { name: args.name }))}`, { ...structured, scope_types: scopeTypes });
54
63
  }
55
64
  }
56
65
  if (!type)
@@ -69,7 +78,12 @@ export const createScopeTool = defineTool({
69
78
  provider,
70
79
  dimensions,
71
80
  });
72
- const orgSlug = await context.org.organizationSlug();
81
+ // Hand off into np-panel: the overview now includes the provisioning scope, so the panel
82
+ // shows it alongside a Deploy button — the literal next move (read-after-write).
83
+ const [orgSlug, status] = await Promise.all([
84
+ context.org.organizationSlug(),
85
+ buildAppStatus(context, app),
86
+ ]);
73
87
  return reply(translate("createScope.provisioning", {
74
88
  name: scope.name,
75
89
  id: scope.id,
@@ -77,6 +91,6 @@ export const createScopeTool = defineTool({
77
91
  dimensions: dimensions ? `, ${dimsLabel(dimensions)}` : "",
78
92
  }) +
79
93
  next(translate("createScope.provisioningHint", { name: scope.name })) +
80
- linkLine(translate("md.dashboard"), dashboardLink(orgSlug, scope.nrn)), { scope });
94
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, scope.nrn)), { ...status.structured, scope, reused: false });
81
95
  },
82
96
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { translate } from "../i18n.js";
3
3
  import { ago, glyph, next, table } from "../md.js";
4
- import { listDeployments, listReleases, listScopes } from "../np/journey.js";
4
+ import { isDeploymentTerminal, listDeployments, listReleases, listScopes, } from "../np/journey.js";
5
5
  import { defineTool, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
7
  import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
@@ -45,6 +45,20 @@ export const deploymentsTool = defineTool({
45
45
  ? translate("deploymentList.group", { count: row.deployments?.length ?? 0 })
46
46
  : (scopeName.get(row.scope_id ?? -1) ?? `scope #${row.scope_id ?? "?"}`);
47
47
  const releaseLabel = (releaseId) => releaseId ? (semverOf.get(releaseId) ?? `#${releaseId}`) : "";
48
+ // A group has no status of its own — derive a headline from its children (same precedence as
49
+ // the widget's collapsed row), so the text reply never prints "· undefined".
50
+ const statusOf = (row) => {
51
+ if (row.type !== "deployment_group")
52
+ return row.status;
53
+ const children = row.deployments ?? [];
54
+ if (children.some((child) => !isDeploymentTerminal(child.status)))
55
+ return "running";
56
+ if (children.some((child) => child.status === "failed" || child.status === "rolled_back"))
57
+ return "failed";
58
+ if (children.length && children.every((child) => child.status === "finalized"))
59
+ return "finalized";
60
+ return children[0]?.status ?? "pending";
61
+ };
48
62
  const markdown = [
49
63
  translate("deploymentList.title", { app: app.name, count: rows.length }),
50
64
  "",
@@ -56,7 +70,7 @@ export const deploymentsTool = defineTool({
56
70
  ], rows.map((row) => [
57
71
  scopeLabel(row),
58
72
  releaseLabel(row.release_id),
59
- `${glyph(row.status)} ${row.status}`,
73
+ `${glyph(statusOf(row))} ${statusOf(row)}`,
60
74
  ago(row.created_at),
61
75
  ])),
62
76
  next(translate("deploymentList.hint")),
@@ -22,6 +22,10 @@ export const logsTool = defineTool({
22
22
  .optional()
23
23
  .describe("ISO-8601 start of the time window, e.g. 2026-06-13T10:00:00Z"),
24
24
  end_time: z.string().optional().describe("ISO-8601 end of the time window (defaults to now)"),
25
+ page_token: z
26
+ .string()
27
+ .optional()
28
+ .describe("Pagination cursor (the next_page_token from a prior call) to fetch the next page of older lines."),
25
29
  },
26
30
  async handler(args, context) {
27
31
  const resolved = await requireApp(context, args);
@@ -45,6 +49,7 @@ export const logsTool = defineTool({
45
49
  const page = await readLogs(context.np, {
46
50
  application_id: app.id,
47
51
  scope_id: scope.id,
52
+ next_page_token: args.page_token,
48
53
  start_time: args.start_time,
49
54
  end_time: args.end_time,
50
55
  });
@@ -16,6 +16,7 @@ export const overviewTool = defineTool({
16
16
  title: "Organization overview",
17
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
18
  annotations: { readOnlyHint: true, openWorldHint: true },
19
+ widget: "overview",
19
20
  errorKey: "overview.errorLabel",
20
21
  inputSchema: {
21
22
  query: z.string().optional().describe("Limit the scan to apps whose name contains this"),
@@ -36,6 +37,7 @@ export const overviewTool = defineTool({
36
37
  if (!isDeploymentTerminal(latest.status)) {
37
38
  active.push({
38
39
  app: app.name,
40
+ app_id: app.id,
39
41
  scope: scope.name,
40
42
  deployment_id: latest.id,
41
43
  status: latest.status,
@@ -46,6 +48,7 @@ export const overviewTool = defineTool({
46
48
  else if (latest.status === "failed" || latest.status === "rolled_back") {
47
49
  trouble.push({
48
50
  app: app.name,
51
+ app_id: app.id,
49
52
  scope: scope.name,
50
53
  status: latest.status,
51
54
  when: latest.created_at,
@@ -16,6 +16,7 @@ export const playbookGetTool = defineTool({
16
16
  title: "Operating playbooks",
17
17
  description: "Read a nullplatform operating playbook — the methodology to follow BEFORE the matching non-trivial work (e.g. deploying-safely before a deploy, incident-response when something is broken, configuring-safely before changing parameters or secrets). The server instructions carry the full catalog; call this with no name to list them too.",
18
18
  annotations: { readOnlyHint: true },
19
+ widget: "playbook",
19
20
  errorKey: "playbook.errorLabel",
20
21
  inputSchema: {
21
22
  name: z.string().optional().describe('Playbook name, e.g. "deploying-safely". Omit to list them.'),
@@ -15,6 +15,7 @@ export const servicesTool = defineTool({
15
15
  title: "Services & dependencies",
16
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
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
+ widget: "services",
18
19
  errorKey: "services.errorLabel",
19
20
  inputSchema: { app: appArg },
20
21
  async handler(args, context) {
@@ -29,7 +30,8 @@ export const servicesTool = defineTool({
29
30
  listServiceSpecifications(context.bff, app.nrn),
30
31
  context.org.organizationSlug(),
31
32
  ]);
32
- const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
33
+ const dashboardUrl = dashboardLink(orgSlug, app.nrn);
34
+ const dashboard = linkLine(translate("md.dashboard"), dashboardUrl);
33
35
  const sections = [];
34
36
  if (services.length === 0) {
35
37
  sections.push(translate("services.none", { app: app.name }));
@@ -53,6 +55,7 @@ export const servicesTool = defineTool({
53
55
  app_name: app.name,
54
56
  services,
55
57
  catalog,
58
+ dashboard: dashboardUrl,
56
59
  });
57
60
  },
58
61
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { translate } from "../i18n.js";
3
3
  import { next } from "../md.js";
4
- import { setParameters } from "../np/journey.js";
4
+ import { listParameters, setParameters } from "../np/journey.js";
5
5
  import { defineTool, errorMessage, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
7
  import { appArg, requireApp } from "./shared.js";
@@ -9,7 +9,8 @@ export const setParamsTool = defineTool({
9
9
  name: TOOL.applicationParameterCreate,
10
10
  title: "Set parameters",
11
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 },
12
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
13
+ widget: "params",
13
14
  errorKey: "setParams.errorLabel",
14
15
  inputSchema: {
15
16
  app: appArg,
@@ -44,10 +45,16 @@ export const setParamsTool = defineTool({
44
45
  app: app.name,
45
46
  names,
46
47
  });
48
+ // Mirror the params widget's shape so the SAME panel renders the resulting set with its
49
+ // add/save affordance (read-after-write); the markdown stays the plain confirmation.
50
+ const parameters = await listParameters(context.np, app.nrn).catch(() => undefined);
47
51
  return reply(summary + next(translate("setParams.applyHint")), {
48
52
  created: result.created,
49
53
  updated: result.updated,
50
54
  total,
55
+ app: `#${app.id}`,
56
+ app_name: app.name,
57
+ ...(parameters === undefined ? { available: false } : { available: true, params: parameters }),
51
58
  });
52
59
  }
53
60
  catch (caught) {
@@ -24,6 +24,20 @@ async function scopeViews(context, applicationId) {
24
24
  };
25
25
  });
26
26
  }
27
+ /**
28
+ * Build an application's full status view (the np-panel overview payload). Exported so write
29
+ * tools that should hand off into the same live panel — e.g. create-scope, after provisioning a
30
+ * new deploy target — can return this exact shape and let np-panel render the app + Deploy.
31
+ */
32
+ export async function buildAppStatus(context, app) {
33
+ const [views, builds, releases, orgSlug] = await Promise.all([
34
+ scopeViews(context, app.id),
35
+ listBuilds(context.np, app.id, { limit: 5 }),
36
+ listReleases(context.np, app.id, { limit: 5 }),
37
+ context.org.organizationSlug(),
38
+ ]);
39
+ return renderStatus({ app, views, builds, releases, dashboard: dashboardLink(orgSlug, app.nrn) });
40
+ }
27
41
  export const statusTool = defineTool({
28
42
  name: TOOL.applicationGet,
29
43
  title: "Application status",
@@ -52,19 +66,7 @@ export const statusTool = defineTool({
52
66
  const resolved = await requireApp(context, args);
53
67
  if ("out" in resolved)
54
68
  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, { limit: 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
- });
69
+ const { md, structured } = await buildAppStatus(context, resolved.app);
68
70
  return reply(md, structured);
69
71
  },
70
72
  });
package/dist/ui.js CHANGED
@@ -22,6 +22,10 @@ export const WIDGETS = {
22
22
  builds: "Builds",
23
23
  releases: "Releases",
24
24
  deployments: "Deployments",
25
+ approvals: "Approvals",
26
+ overview: "Organization overview",
27
+ services: "Services & dependencies",
28
+ playbook: "Operating playbooks",
25
29
  };
26
30
  /** Did this session's client negotiate the MCP Apps extension? (Knowable after initialize.) */
27
31
  export function uiNegotiated(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "nullplatform from your code assistant — an MCP server that replaces the dashboard for the everyday developer journey",
5
5
  "license": "MIT",
6
6
  "author": "nullplatform",
@@ -37,6 +37,7 @@
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
39
39
  "build:widgets": "node scripts/build-widgets.mjs",
40
+ "preview:widgets": "node scripts/preview/build-preview.mjs",
40
41
  "pretest": "node scripts/build-widgets.mjs",
41
42
  "lint": "biome check .",
42
43
  "lint:fix": "biome check --write .",