@nullplatform/mcp 0.1.4 → 0.1.6

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/dist/md.js CHANGED
@@ -1,4 +1,4 @@
1
- import { translate } from "./i18n.js";
1
+ import { currentLocale, translate } from "./i18n.js";
2
2
  /**
3
3
  * The output design language. Every tool answer is markdown a developer scans in a
4
4
  * terminal or chat pane: a bold header line, tight tables, one status glyph per row,
@@ -47,7 +47,8 @@ export function statusLabel(status) {
47
47
  export function ago(iso) {
48
48
  if (!iso)
49
49
  return "";
50
- const elapsedMs = Date.now() - new Date(iso).getTime();
50
+ const date = new Date(iso);
51
+ const elapsedMs = Date.now() - date.getTime();
51
52
  if (!Number.isFinite(elapsedMs) || elapsedMs < 0)
52
53
  return "";
53
54
  const minutes = Math.floor(elapsedMs / 60_000);
@@ -56,9 +57,18 @@ export function ago(iso) {
56
57
  if (minutes < 60)
57
58
  return translate("md.minutesAgo", { count: minutes });
58
59
  const hours = Math.round(minutes / 60);
59
- if (hours < 48)
60
+ if (hours < 24)
60
61
  return translate("md.hoursAgo", { count: hours });
61
- return translate("md.daysAgo", { count: Math.round(hours / 24) });
62
+ const days = Math.round(hours / 24);
63
+ if (days < 7)
64
+ return translate("md.daysAgo", { count: days });
65
+ // GitHub-style: a week or older shows an absolute calendar date (current year omitted), localized.
66
+ const sameYear = date.getFullYear() === new Date().getFullYear();
67
+ return new Intl.DateTimeFormat(currentLocale(), {
68
+ month: "short",
69
+ day: "numeric",
70
+ ...(sameYear ? {} : { year: "numeric" }),
71
+ }).format(date);
62
72
  }
63
73
  export function table(headers, rows) {
64
74
  if (rows.length === 0)
@@ -12,7 +12,7 @@ You run in the developer's own environment, so fuse the local repo with platform
12
12
 
13
13
  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.
14
14
 
15
- 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.
15
+ Most tools render an interactive panel in clients that support it — apps, status, builds, releases, deployments, logs, metrics, parameters and approvals all appear as live UI. **When a tool's panel renders, that panel IS the answer: do not reproduce its data in your text reply.** The user already sees every row, status and value. NEVER print a markdown table of the same rows, re-list the items, or restate per-row status — duplicating the panel in text is the single most common mistake. Reply with AT MOST one short sentence — the one key takeaway or the next step — or nothing at all. A one-line insight the panel doesn't itself show is fine ("builds 3 and 5 were never released"); re-rendering the list as a table is not.
16
16
 
17
17
  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.
18
18
 
@@ -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.6",
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 .",