@nullplatform/mcp 0.1.6 → 0.1.8

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 (47) hide show
  1. package/dist/i18n.js +599 -24
  2. package/dist/np/client.js +3 -0
  3. package/dist/np/journey.js +236 -24
  4. package/dist/tool-names.js +7 -0
  5. package/dist/tools/action-flow.js +94 -0
  6. package/dist/tools/approvals.js +2 -2
  7. package/dist/tools/builds.js +23 -6
  8. package/dist/tools/create-app.js +37 -9
  9. package/dist/tools/create-link.js +163 -0
  10. package/dist/tools/create-service.js +149 -0
  11. package/dist/tools/delete-flow.js +30 -0
  12. package/dist/tools/delete-link.js +95 -0
  13. package/dist/tools/delete-param.js +76 -0
  14. package/dist/tools/delete-service.js +108 -0
  15. package/dist/tools/deployments.js +2 -2
  16. package/dist/tools/index.js +14 -0
  17. package/dist/tools/logs.js +2 -2
  18. package/dist/tools/metrics.js +4 -1
  19. package/dist/tools/overview.js +2 -2
  20. package/dist/tools/params.js +78 -17
  21. package/dist/tools/playbook.js +2 -1
  22. package/dist/tools/releases.js +2 -2
  23. package/dist/tools/services.js +2 -2
  24. package/dist/tools/set-params.js +61 -11
  25. package/dist/tools/shared.js +4 -0
  26. package/dist/tools/update-link.js +87 -0
  27. package/dist/tools/update-service.js +92 -0
  28. package/dist/ui.js +4 -1
  29. package/package.json +3 -1
  30. package/skills/starting-a-new-app/SKILL.md +84 -0
  31. package/widgets-dist/approvals.html +261 -57
  32. package/widgets-dist/builds.html +261 -57
  33. package/widgets-dist/create-app.html +257 -53
  34. package/widgets-dist/deployments.html +251 -47
  35. package/widgets-dist/find-apps.html +257 -53
  36. package/widgets-dist/logs.html +256 -52
  37. package/widgets-dist/manifest.json +16 -13
  38. package/widgets-dist/metrics.html +261 -57
  39. package/widgets-dist/np-panel.html +257 -53
  40. package/widgets-dist/overview.html +257 -53
  41. package/widgets-dist/params.html +261 -57
  42. package/widgets-dist/releases.html +257 -53
  43. package/widgets-dist/service-action.html +1118 -0
  44. package/widgets-dist/service-create.html +1117 -0
  45. package/widgets-dist/{playbook.html → service-delete.html} +258 -58
  46. package/widgets-dist/service-link.html +1117 -0
  47. package/widgets-dist/services.html +256 -52
@@ -1,8 +1,13 @@
1
1
  import { approvalsTool } from "./approvals.js";
2
2
  import { buildsTool } from "./builds.js";
3
3
  import { createAppTool } from "./create-app.js";
4
+ import { createLinkTool } from "./create-link.js";
4
5
  import { createReleaseTool } from "./create-release.js";
5
6
  import { createScopeTool } from "./create-scope.js";
7
+ import { createServiceTool } from "./create-service.js";
8
+ import { deleteLinkTool } from "./delete-link.js";
9
+ import { deleteParamTool } from "./delete-param.js";
10
+ import { deleteServiceTool } from "./delete-service.js";
6
11
  import { deployTool } from "./deploy.js";
7
12
  import { deploymentsTool } from "./deployments.js";
8
13
  import { findAppsTool } from "./find-apps.js";
@@ -16,6 +21,8 @@ import { servicesTool } from "./services.js";
16
21
  import { setParamsTool } from "./set-params.js";
17
22
  import { statusTool } from "./status.js";
18
23
  import { trafficTool } from "./traffic.js";
24
+ import { updateLinkTool } from "./update-link.js";
25
+ import { updateServiceTool } from "./update-service.js";
19
26
  /**
20
27
  * The tool registry — the single extension point. To add a tool: create
21
28
  * src/tools/<name>.ts exporting a defineTool({...}) and list it here. Its widget
@@ -39,5 +46,12 @@ export const tools = [
39
46
  createScopeTool,
40
47
  approvalsTool,
41
48
  servicesTool,
49
+ createServiceTool,
50
+ updateServiceTool,
51
+ deleteServiceTool,
52
+ createLinkTool,
53
+ updateLinkTool,
54
+ deleteLinkTool,
55
+ deleteParamTool,
42
56
  playbookGetTool,
43
57
  ];
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { dashboardLink, linkLine, next } from "../md.js";
4
4
  import { listScopes, readLogs } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
@@ -71,7 +71,7 @@ export const logsTool = defineTool({
71
71
  return `${prefix}${(entry.message ?? "").trimEnd()}`;
72
72
  })
73
73
  .join("\n");
74
- const markdown = `${translate("logs.lastLines", { app: app.name, scope: scopeSuffix, count: entries.length })}\n\n\`\`\`\n${body}\n\`\`\``;
74
+ const markdown = `${plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", { app: app.name, scope: scopeSuffix })}\n\n\`\`\`\n${body}\n\`\`\``;
75
75
  return reply(markdown, {
76
76
  count: entries.length,
77
77
  next_page_token: page.next_page_token ?? null,
@@ -34,6 +34,8 @@ export const metricsTool = defineTool({
34
34
  if ("out" in picked)
35
35
  return picked.out;
36
36
  const scope = picked.scope;
37
+ // The full scope list rides along so the widget can offer a scope switcher (same as logs).
38
+ const scopeList = scopes.map((candidate) => ({ name: candidate.name, status: candidate.status }));
37
39
  const window = args.window ?? "3h";
38
40
  const series = await readGoldenMetrics(context.bff, {
39
41
  application_id: app.id,
@@ -45,7 +47,7 @@ export const metricsTool = defineTool({
45
47
  if (withData.length === 0) {
46
48
  const orgSlug = await context.org.organizationSlug();
47
49
  return reply(translate("metrics.empty", { app: app.name, scope: scope.name, window }) +
48
- linkLine(translate("metrics.openInDashboard"), dashboardLink(orgSlug, scope.nrn)), { app: `#${app.id}`, scope: scope.name, window, series: [] });
50
+ linkLine(translate("metrics.openInDashboard"), dashboardLink(orgSlug, scope.nrn)), { app: `#${app.id}`, app_name: app.name, scope: scope.name, scopes: scopeList, window, series: [] });
49
51
  }
50
52
  const markdown = [
51
53
  translate("metrics.title", { app: app.name, scope: scope.name, window }),
@@ -68,6 +70,7 @@ export const metricsTool = defineTool({
68
70
  app: `#${app.id}`,
69
71
  app_name: app.name,
70
72
  scope: scope.name,
73
+ scopes: scopeList,
71
74
  window,
72
75
  series: series.map((metric) => ({
73
76
  id: metric.id,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, glyph, next, table } from "../md.js";
4
4
  import { pmap } from "../np/context.js";
5
5
  import { isDeploymentTerminal, listScopeDeployments, listScopes } from "../np/journey.js";
@@ -66,7 +66,7 @@ export const overviewTool = defineTool({
66
66
  });
67
67
  function renderOverview(args) {
68
68
  const { active, trouble, scanned, truncated } = args;
69
- const lines = [translate("overview.title", { count: scanned })];
69
+ const lines = [plural(scanned, "overview.title.one", "overview.title.many")];
70
70
  if (truncated)
71
71
  lines.push(translate("overview.truncated", { count: MAX_APPS }));
72
72
  lines.push("");
@@ -1,17 +1,34 @@
1
1
  import { plural, translate } from "../i18n.js";
2
2
  import { dashboardLink, linkLine, next, table } from "../md.js";
3
- import { listParameters } from "../np/journey.js";
3
+ import { listParameters, listScopes } from "../np/journey.js";
4
4
  import { defineTool, fail, reply } from "../tool.js";
5
5
  import { TOOL } from "../tool-names.js";
6
- import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
6
+ import { appArg, dimsLabel, offsetArg, pageOf, pickScope, requireApp, scopeArg } from "./shared.js";
7
+ /** Where a value applies, in words: the base (Application), a dimension set, or a named scope. */
8
+ function contextLabel(value, scopeNameById) {
9
+ if (value.scope_id) {
10
+ return translate("params.ctxScope", { scope: scopeNameById.get(value.scope_id) ?? `#${value.scope_id}` });
11
+ }
12
+ if (value.dimensions)
13
+ return dimsLabel(value.dimensions);
14
+ return translate("params.ctxApplication");
15
+ }
16
+ /** The full value set of a parameter, summarized as "context: value" pairs for the text table. */
17
+ function valueSummary(parameter, scopeNameById) {
18
+ if (!parameter.values.length)
19
+ return translate("params.unset");
20
+ return parameter.values
21
+ .map((value) => `${contextLabel(value, scopeNameById)}: ${value.value ?? translate("params.unset")}`)
22
+ .join(" · ");
23
+ }
7
24
  export const paramsTool = defineTool({
8
25
  name: TOOL.applicationParameterList,
9
26
  title: "Parameters",
10
- description: "List an application's configuration parameters (env vars / files; secret values are masked). Use application_parameter_create to add or change them.",
27
+ description: "List an application's configuration parameters and every value they hold across scopes and dimensions (env vars / files; secret values are masked). Pass `scope` to resolve the single effective value per parameter for that scope. Use application_parameter_create to add or change them.",
11
28
  annotations: { readOnlyHint: true, openWorldHint: true },
12
29
  widget: "params",
13
30
  errorKey: "params.errorLabel",
14
- inputSchema: { app: appArg, offset: offsetArg },
31
+ inputSchema: { app: appArg, scope: scopeArg, offset: offsetArg },
15
32
  async handler(args, context) {
16
33
  const resolved = await requireApp(context, args);
17
34
  if ("out" in resolved)
@@ -19,12 +36,32 @@ export const paramsTool = defineTool({
19
36
  const app = resolved.app;
20
37
  if (!app.nrn)
21
38
  return fail(translate("resolve.noNrn", { app: app.name }));
22
- const [parameters, orgSlug] = await Promise.all([
23
- listParameters(context.np, app.nrn, { offset: args.offset }),
39
+ const [scopes, orgSlug] = await Promise.all([
40
+ listScopes(context.np, app.id),
24
41
  context.org.organizationSlug(),
25
42
  ]);
43
+ const scopeNameById = new Map(scopes.map((scope) => [scope.id, scope.name]));
26
44
  const dashboard = dashboardLink(orgSlug, app.nrn);
27
- const appRef = { app: `#${app.id}`, app_name: app.name };
45
+ // scopes ride along so the widget can build its scope picker AND derive the by-dimension targets
46
+ const appRef = {
47
+ app: `#${app.id}`,
48
+ app_name: app.name,
49
+ scopes: scopes.map((scope) => ({ id: scope.id, name: scope.name, dimensions: scope.dimensions ?? {} })),
50
+ };
51
+ // With a scope, read at the scope NRN so the platform collapses each parameter to its single
52
+ // effective value (scope value › most-specific dimension match › app default).
53
+ let resolvedScope;
54
+ let readNrn = app.nrn;
55
+ if (args.scope) {
56
+ const picked = pickScope(scopes, args.scope);
57
+ if ("out" in picked)
58
+ return picked.out;
59
+ if (!picked.scope.nrn)
60
+ return fail(translate("resolve.noNrn", { app: picked.scope.name }));
61
+ resolvedScope = { id: picked.scope.id, name: picked.scope.name };
62
+ readNrn = picked.scope.nrn;
63
+ }
64
+ const parameters = await listParameters(context.np, readNrn, { offset: args.offset });
28
65
  if (parameters === undefined) {
29
66
  return reply(translate("params.unavailable") + linkLine(translate("params.viewInDashboard"), dashboard), { available: false, ...appRef });
30
67
  }
@@ -35,6 +72,35 @@ export const paramsTool = defineTool({
35
72
  ...appRef,
36
73
  });
37
74
  }
75
+ const payload = {
76
+ available: true,
77
+ params: parameters,
78
+ page: pageOf(parameters.length, 100, args.offset ?? 0),
79
+ ...(resolvedScope ? { resolved_scope: resolvedScope } : {}),
80
+ ...appRef,
81
+ };
82
+ if (resolvedScope) {
83
+ const markdown = [
84
+ translate("params.effectiveFor", { app: app.name, scope: resolvedScope.name }),
85
+ "",
86
+ table([
87
+ translate("header.name"),
88
+ translate("header.variable"),
89
+ translate("header.value"),
90
+ translate("header.source"),
91
+ ], parameters.map((parameter) => {
92
+ const effective = parameter.values[0];
93
+ return [
94
+ parameter.name,
95
+ parameter.variable ?? parameter.destination_path ?? "",
96
+ effective?.value ?? translate("params.unset"),
97
+ effective ? contextLabel(effective, scopeNameById) : "—",
98
+ ];
99
+ })),
100
+ next(translate("params.applyHint")),
101
+ ].join("\n");
102
+ return reply(markdown, payload);
103
+ }
38
104
  const markdown = [
39
105
  plural(parameters.length, "params.count.one", "params.count.many", { app: app.name }),
40
106
  "",
@@ -43,21 +109,16 @@ export const paramsTool = defineTool({
43
109
  translate("header.variable"),
44
110
  translate("header.type"),
45
111
  translate("header.secret"),
46
- translate("header.value"),
112
+ translate("header.values"),
47
113
  ], parameters.map((parameter) => [
48
114
  parameter.name,
49
- parameter.variable ?? "",
50
- parameter.type ?? "ENVIRONMENT",
115
+ parameter.variable ?? parameter.destination_path ?? "",
116
+ parameter.type ?? "environment",
51
117
  parameter.secret ? "🔒" : "",
52
- parameter.values[0]?.value ?? translate("params.unset"),
118
+ valueSummary(parameter, scopeNameById),
53
119
  ])),
54
120
  next(translate("params.applyHint")),
55
121
  ].join("\n");
56
- return reply(markdown, {
57
- available: true,
58
- params: parameters,
59
- page: pageOf(parameters.length, 100, args.offset ?? 0),
60
- ...appRef,
61
- });
122
+ return reply(markdown, payload);
62
123
  },
63
124
  });
@@ -16,7 +16,8 @@ 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
+ // No widget: a playbook is model-facing methodology prose, not an interactive entity — it
20
+ // renders as text (the markdown below) for the model to read and act on. See CLAUDE.md.
20
21
  errorKey: "playbook.errorLabel",
21
22
  inputSchema: {
22
23
  name: z.string().optional().describe('Playbook name, e.g. "deploying-safely". Omit to list them.'),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, glyph, next, table } from "../md.js";
4
4
  import { listReleases } from "../np/journey.js";
5
5
  import { defineTool, reply } from "../tool.js";
@@ -34,7 +34,7 @@ export const releasesTool = defineTool({
34
34
  return reply(translate("releaseList.none", { app: app.name }) + next(translate("releaseList.noneHint")), { app: `#${app.id}`, app_name: app.name, releases: [] });
35
35
  }
36
36
  const markdown = [
37
- translate("releaseList.title", { app: app.name, count: releases.length }),
37
+ plural(releases.length, "releaseList.title.one", "releaseList.title.many", { app: app.name }),
38
38
  "",
39
39
  table([
40
40
  translate("header.version"),
@@ -1,4 +1,4 @@
1
- import { translate } from "../i18n.js";
1
+ import { plural, translate } from "../i18n.js";
2
2
  import { dashboardLink, glyph, linkLine, next, table } from "../md.js";
3
3
  import { listServiceSpecifications, listServices } from "../np/journey.js";
4
4
  import { defineTool, fail, reply } from "../tool.js";
@@ -37,7 +37,7 @@ export const servicesTool = defineTool({
37
37
  sections.push(translate("services.none", { app: app.name }));
38
38
  }
39
39
  else {
40
- sections.push(translate("services.attached", { app: app.name, count: services.length }));
40
+ sections.push(plural(services.length, "services.attached.one", "services.attached.many", { app: app.name }));
41
41
  sections.push("");
42
42
  sections.push(table([translate("header.name"), translate("services.specification"), translate("header.status")], services.map((service) => [
43
43
  service.name,
@@ -1,14 +1,14 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { next } from "../md.js";
4
- import { listParameters, setParameters } from "../np/journey.js";
4
+ import { listParameters, listScopes, setParameters } from "../np/journey.js";
5
5
  import { defineTool, errorMessage, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
- import { appArg, requireApp } from "./shared.js";
7
+ import { appArg, pickScope, requireApp } from "./shared.js";
8
8
  export const setParamsTool = defineTool({
9
9
  name: TOOL.applicationParameterCreate,
10
10
  title: "Set parameters",
11
- description: "Create or update application configuration parameters (environment variables or files; mark secrets). Values apply on the NEXT deploy.",
11
+ description: "Create or update application configuration parameters (environment variables or files; mark secrets). Target a value at the application (default — every scope), by dimension (applies to any matching scope), or pinned to one scope. Values apply on the NEXT deploy.",
12
12
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
13
13
  widget: "params",
14
14
  errorKey: "setParams.errorLabel",
@@ -18,8 +18,19 @@ export const setParamsTool = defineTool({
18
18
  .array(z.object({
19
19
  name: z.string().describe("Variable name, e.g. DATABASE_URL"),
20
20
  value: z.string(),
21
- secret: z.boolean().optional().describe("Mask the value (default false)"),
21
+ secret: z
22
+ .boolean()
23
+ .optional()
24
+ .describe("Mask the value — only honored when first creating the parameter (secrecy is immutable after)"),
22
25
  type: z.enum(["ENVIRONMENT", "FILE"]).optional().describe("Default ENVIRONMENT"),
26
+ scope: z
27
+ .string()
28
+ .optional()
29
+ .describe('Pin this value to ONE scope (name, "#id", or "key=value") — the last-resort target. Do not combine with dimensions.'),
30
+ dimensions: z
31
+ .record(z.string())
32
+ .optional()
33
+ .describe('Target by dimension, e.g. {"environment":"production"} — the value then applies to any scope whose dimensions match. Do not combine with scope.'),
23
34
  }))
24
35
  .min(1),
25
36
  },
@@ -30,23 +41,57 @@ export const setParamsTool = defineTool({
30
41
  const app = resolved.app;
31
42
  if (!app.nrn)
32
43
  return fail(translate("resolve.noNrn", { app: app.name }));
44
+ // A scope NRN already locates the value; dimensions only apply at the application NRN. The
45
+ // platform rejects scope-NRN + dimensions (SCOPE_NRN_DIMENSIONS_AMBIGUITY) — catch it early.
46
+ const conflict = args.params.find((param) => param.scope && param.dimensions && Object.keys(param.dimensions).length);
47
+ if (conflict)
48
+ return fail(translate("setParams.scopeDimConflict", { name: conflict.name }));
49
+ // One scope fetch — used both to resolve scope refs to NRNs and to feed the widget's picker.
50
+ const scopes = await listScopes(context.np, app.id).catch(() => []);
51
+ const inputs = [];
52
+ for (const param of args.params) {
53
+ let nrn;
54
+ if (param.scope) {
55
+ const picked = pickScope(scopes, param.scope);
56
+ if ("out" in picked)
57
+ return picked.out;
58
+ if (!picked.scope.nrn)
59
+ return fail(translate("resolve.noNrn", { app: picked.scope.name }));
60
+ nrn = picked.scope.nrn;
61
+ }
62
+ inputs.push({
63
+ name: param.name,
64
+ value: param.value,
65
+ secret: param.secret,
66
+ type: param.type,
67
+ nrn,
68
+ dimensions: param.dimensions,
69
+ });
70
+ }
33
71
  try {
34
- const result = await setParameters(context.np, app.nrn, args.params);
35
- const names = args.params.map((parameter) => `\`${parameter.name}\``).join(", ");
72
+ const result = await setParameters(context.np, app.nrn, inputs);
73
+ const names = args.params.map((param) => `\`${param.name}\``).join(", ");
36
74
  const total = result.created + result.updated;
37
75
  // Honest about upsert: say how many were new vs changed so a retry reads as "0 new".
38
76
  const summary = result.updated === 0
39
- ? translate("setParams.created", { count: result.created, app: app.name, names })
77
+ ? plural(result.created, "setParams.created.one", "setParams.created.many", {
78
+ app: app.name,
79
+ names,
80
+ })
40
81
  : result.created === 0
41
- ? translate("setParams.updated", { count: result.updated, app: app.name, names })
82
+ ? plural(result.updated, "setParams.updated.one", "setParams.updated.many", {
83
+ app: app.name,
84
+ names,
85
+ })
42
86
  : translate("setParams.mixed", {
43
87
  created: result.created,
44
88
  updated: result.updated,
89
+ total,
45
90
  app: app.name,
46
91
  names,
47
92
  });
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.
93
+ // Mirror the params widget's shape so the SAME panel renders the resulting set (with its
94
+ // scope picker + add affordance) after the write; the markdown stays the plain confirmation.
50
95
  const parameters = await listParameters(context.np, app.nrn).catch(() => undefined);
51
96
  return reply(summary + next(translate("setParams.applyHint")), {
52
97
  created: result.created,
@@ -54,6 +99,11 @@ export const setParamsTool = defineTool({
54
99
  total,
55
100
  app: `#${app.id}`,
56
101
  app_name: app.name,
102
+ scopes: scopes.map((scope) => ({
103
+ id: scope.id,
104
+ name: scope.name,
105
+ dimensions: scope.dimensions ?? {},
106
+ })),
57
107
  ...(parameters === undefined ? { available: false } : { available: true, params: parameters }),
58
108
  });
59
109
  }
@@ -15,6 +15,10 @@ export const offsetArg = z
15
15
  .number()
16
16
  .optional()
17
17
  .describe("Skip this many items to page through a long list (use page.next_offset from a prior call).");
18
+ export const scopeArg = z
19
+ .string()
20
+ .optional()
21
+ .describe('Scope name, "#id", or a dimension match like "environment=production". Omit to list every value across all scopes and dimensions.');
18
22
  /**
19
23
  * Pagination token for a list reply. No list endpoint returns a total, so the boundary is the
20
24
  * standard offset heuristic: a full page means there is likely more. The widget/model pages by
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, next, table } from "../md.js";
4
+ import { createLinkAction, getLinkAction, linkActionSpecs, listLinks } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { runActionFlow } from "./action-flow.js";
8
+ import { appArg, requireApp } from "./shared.js";
9
+ /**
10
+ * Run a custom action on a link — the platform-team-defined operations a link specification exposes
11
+ * under "Run action". Not every link spec has custom actions (many only have create/delete). Creating
12
+ * a link is `application_link_create`, removing it is `application_link_delete`. Form-first, same as
13
+ * the service variant.
14
+ */
15
+ export const updateLinkTool = defineTool({
16
+ name: TOOL.applicationLinkUpdate,
17
+ title: "Run link action",
18
+ description: "Run a custom action on a link (a service↔application connection) — the platform-team-defined operations its link specification exposes. Pass `link` to choose which one (by name) and `action` to choose which action; call WITHOUT `run:true` first to open the form. Not every link has custom actions.",
19
+ annotations: { destructiveHint: false, openWorldHint: true },
20
+ widget: "service-action",
21
+ errorKey: "runAction.errorLabel",
22
+ inputSchema: {
23
+ app: appArg,
24
+ link: z.string().optional().describe("Which link to operate, by name (or the link id)"),
25
+ action: z.string().optional().describe("Which custom action to run, by name (or its slug)"),
26
+ parameters: z
27
+ .record(z.unknown())
28
+ .optional()
29
+ .describe("Values for the action parameters (per the action spec's schema shown in the form)"),
30
+ run: z
31
+ .boolean()
32
+ .optional()
33
+ .describe("Set true to actually run the action (the form's submit sets it). Omit to return the form."),
34
+ },
35
+ async handler(args, context) {
36
+ const resolved = await requireApp(context, args);
37
+ if ("out" in resolved)
38
+ return resolved.out;
39
+ const app = resolved.app;
40
+ if (!app.nrn)
41
+ return fail(translate("resolve.noNrn", { app: app.name }));
42
+ const orgSlug = await context.org.organizationSlug();
43
+ const dashboard = dashboardLink(orgSlug, app.nrn);
44
+ // The app's links — pick which one to operate.
45
+ const links = (await listLinks(context.np, app.nrn)).filter((link) => link.status !== "failed");
46
+ if (links.length === 0) {
47
+ return fail(translate("runAction.noLinks", { app: app.name }) + next(translate("runAction.noLinksHint")));
48
+ }
49
+ const wanted = args.link?.toLowerCase();
50
+ const matched = wanted
51
+ ? links.filter((link) => link.name.toLowerCase().includes(wanted) || link.id === args.link)
52
+ : [];
53
+ if (matched.length !== 1) {
54
+ const list = matched.length > 1 ? matched : links;
55
+ const md = `${translate(matched.length > 1 ? "runAction.linkAmbiguous" : "runAction.pickLink", {
56
+ app: app.name,
57
+ })}\n\n${table([translate("header.name"), translate("header.status")], list.map((link) => [link.name, link.status]))}${next(translate("runAction.pickLinkHint"))}`;
58
+ return reply(md, {
59
+ mode: "pick-target",
60
+ entity: "link",
61
+ tool: TOOL.applicationLinkUpdate,
62
+ app: `#${app.id}`,
63
+ app_name: app.name,
64
+ targets: list,
65
+ dashboard,
66
+ });
67
+ }
68
+ const link = matched[0];
69
+ const specs = link.specification_id
70
+ ? (await linkActionSpecs(context.np, link.specification_id)).filter((spec) => spec.type === "custom")
71
+ : [];
72
+ const config = {
73
+ tool: TOOL.applicationLinkUpdate,
74
+ entity: "link",
75
+ appRef: `#${app.id}`,
76
+ appName: app.name,
77
+ entityId: link.id,
78
+ entityName: link.name,
79
+ entityStatus: link.status,
80
+ specs,
81
+ dashboard,
82
+ run: (input) => createLinkAction(context.np, link.id, input),
83
+ poll: (actionId) => getLinkAction(context.np, link.id, actionId),
84
+ };
85
+ return runActionFlow(config, args);
86
+ },
87
+ });
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, next, table } from "../md.js";
4
+ import { createServiceAction, getServiceAction, listAppServices, serviceActionSpecs } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { runActionFlow } from "./action-flow.js";
8
+ import { appArg, requireApp } from "./shared.js";
9
+ /**
10
+ * Run a custom action on a provisioned service — the platform-team-defined ad-hoc operations the
11
+ * dashboard surfaces under "Run action" (e.g. a Postgres "Run DML Query" / "Run DDL Query"). These
12
+ * are the action specs of `type: custom`; provisioning (`create`) is `application_service_create`,
13
+ * and deprovisioning (`delete`) is `application_service_delete`. Form-first: nothing runs until the
14
+ * user submits, because a custom action is an explicit, non-idempotent operation.
15
+ */
16
+ export const updateServiceTool = defineTool({
17
+ name: TOOL.applicationServiceUpdate,
18
+ title: "Run service action",
19
+ description: "Run a custom action on a provisioned service — the platform-team-defined operations a service exposes (e.g. a Postgres database's DML/DDL query runners). Pass `service` to choose which one (by name) and `action` to choose which action; call WITHOUT `run:true` first to open the form, the user confirms by submitting it. A custom action is an explicit operation (not idempotent), so it never runs without that submit.",
20
+ annotations: { destructiveHint: false, openWorldHint: true },
21
+ widget: "service-action",
22
+ errorKey: "runAction.errorLabel",
23
+ inputSchema: {
24
+ app: appArg,
25
+ service: z
26
+ .string()
27
+ .optional()
28
+ .describe("Which provisioned service to operate, by name (or the service id)"),
29
+ action: z.string().optional().describe("Which custom action to run, by name (or its slug)"),
30
+ parameters: z
31
+ .record(z.unknown())
32
+ .optional()
33
+ .describe("Values for the action parameters (per the action spec's schema shown in the form)"),
34
+ run: z
35
+ .boolean()
36
+ .optional()
37
+ .describe("Set true to actually run the action (the form's submit sets it). Omit to return the form."),
38
+ },
39
+ async handler(args, context) {
40
+ const resolved = await requireApp(context, args);
41
+ if ("out" in resolved)
42
+ return resolved.out;
43
+ const app = resolved.app;
44
+ if (!app.nrn)
45
+ return fail(translate("resolve.noNrn", { app: app.name }));
46
+ const orgSlug = await context.org.organizationSlug();
47
+ const dashboard = dashboardLink(orgSlug, app.nrn);
48
+ // The app's provisioned services — pick which one to operate.
49
+ const services = (await listAppServices(context.np, app.nrn)).filter((service) => service.status !== "failed");
50
+ if (services.length === 0) {
51
+ return fail(translate("runAction.noServices", { app: app.name }) + next(translate("runAction.noServicesHint")));
52
+ }
53
+ const wanted = args.service?.toLowerCase();
54
+ const matched = wanted
55
+ ? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === args.service)
56
+ : [];
57
+ if (matched.length !== 1) {
58
+ const list = matched.length > 1 ? matched : services;
59
+ const md = `${translate(matched.length > 1 ? "runAction.serviceAmbiguous" : "runAction.pickService", {
60
+ app: app.name,
61
+ })}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("runAction.pickServiceHint"))}`;
62
+ return reply(md, {
63
+ mode: "pick-target",
64
+ entity: "service",
65
+ tool: TOOL.applicationServiceUpdate,
66
+ app: `#${app.id}`,
67
+ app_name: app.name,
68
+ targets: list,
69
+ dashboard,
70
+ });
71
+ }
72
+ const service = matched[0];
73
+ // Only `custom` action specs are user-runnable (create/update/delete drive the lifecycle).
74
+ const specs = service.specification_id
75
+ ? (await serviceActionSpecs(context.np, service.specification_id)).filter((spec) => spec.type === "custom")
76
+ : [];
77
+ const config = {
78
+ tool: TOOL.applicationServiceUpdate,
79
+ entity: "service",
80
+ appRef: `#${app.id}`,
81
+ appName: app.name,
82
+ entityId: service.id,
83
+ entityName: service.name,
84
+ entityStatus: service.status,
85
+ specs,
86
+ dashboard,
87
+ run: (input) => createServiceAction(context.np, service.id, input),
88
+ poll: (actionId) => getServiceAction(context.np, service.id, actionId),
89
+ };
90
+ return runActionFlow(config, args);
91
+ },
92
+ });
package/dist/ui.js CHANGED
@@ -25,7 +25,10 @@ export const WIDGETS = {
25
25
  approvals: "Approvals",
26
26
  overview: "Organization overview",
27
27
  services: "Services & dependencies",
28
- playbook: "Operating playbooks",
28
+ "service-create": "Provision service",
29
+ "service-link": "Link service",
30
+ "service-action": "Run action",
31
+ "service-delete": "Delete service",
29
32
  };
30
33
  /** Did this session's client negotiate the MCP Apps extension? (Knowable after initialize.) */
31
34
  export function uiNegotiated(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -50,6 +50,8 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@biomejs/biome": "2.4.16",
53
+ "@jsonforms/core": "^3.5.1",
54
+ "@jsonforms/react": "^3.5.1",
53
55
  "@testing-library/react": "^16.3.2",
54
56
  "@types/node": "^20.14.0",
55
57
  "@types/react": "^19.2.17",