@nullplatform/mcp 0.1.6 → 0.1.7

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 (46) hide show
  1. package/dist/i18n.js +206 -22
  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-link.js +163 -0
  9. package/dist/tools/create-service.js +149 -0
  10. package/dist/tools/delete-flow.js +30 -0
  11. package/dist/tools/delete-link.js +95 -0
  12. package/dist/tools/delete-param.js +76 -0
  13. package/dist/tools/delete-service.js +108 -0
  14. package/dist/tools/deployments.js +2 -2
  15. package/dist/tools/index.js +14 -0
  16. package/dist/tools/logs.js +2 -2
  17. package/dist/tools/metrics.js +4 -1
  18. package/dist/tools/overview.js +2 -2
  19. package/dist/tools/params.js +78 -17
  20. package/dist/tools/playbook.js +2 -1
  21. package/dist/tools/releases.js +2 -2
  22. package/dist/tools/services.js +2 -2
  23. package/dist/tools/set-params.js +61 -11
  24. package/dist/tools/shared.js +4 -0
  25. package/dist/tools/update-link.js +87 -0
  26. package/dist/tools/update-service.js +92 -0
  27. package/dist/ui.js +4 -1
  28. package/package.json +3 -1
  29. package/skills/starting-a-new-app/SKILL.md +71 -0
  30. package/widgets-dist/approvals.html +261 -57
  31. package/widgets-dist/builds.html +256 -52
  32. package/widgets-dist/create-app.html +252 -48
  33. package/widgets-dist/deployments.html +251 -47
  34. package/widgets-dist/find-apps.html +251 -47
  35. package/widgets-dist/logs.html +256 -52
  36. package/widgets-dist/manifest.json +16 -13
  37. package/widgets-dist/metrics.html +261 -57
  38. package/widgets-dist/np-panel.html +256 -52
  39. package/widgets-dist/overview.html +256 -52
  40. package/widgets-dist/params.html +257 -53
  41. package/widgets-dist/releases.html +257 -53
  42. package/widgets-dist/service-action.html +1118 -0
  43. package/widgets-dist/service-create.html +1117 -0
  44. package/widgets-dist/{playbook.html → service-delete.html} +258 -58
  45. package/widgets-dist/service-link.html +1117 -0
  46. package/widgets-dist/services.html +249 -45
@@ -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.7",
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",
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: starting-a-new-app
3
+ description: Use when standing up something NEW on nullplatform — a new application (or a small set, e.g. a frontend and its API), choosing where it lives, wiring its data dependencies, and getting it to a first running deploy before any features are built. The greenfield counterpart to working-from-a-ticket.
4
+ ---
5
+
6
+ # Starting a new app on nullplatform
7
+
8
+ nullplatform owns the *platform* half — where the app lives, how it ships, what it depends on.
9
+ The code is your own craft. This playbook carries a new thing from a request to its first running
10
+ deploy with its dependencies wired, then hands the feature-building back to you. Do it in order:
11
+ each step de-risks the next. Standing up the skeleton and *then* coding beats coding against a
12
+ platform you haven't proven yet.
13
+
14
+ ## 1. Place it before you create it
15
+
16
+ `application_create` opens a pre-filled form — but *where* it goes is a real decision, not a
17
+ default. Read the request for the home it implies (a demo for a banking client in Argentina is not
18
+ the same namespace as a throwaway): list the namespaces the form offers and pick the account +
19
+ namespace that fits, and confirm that choice for anything that isn't obviously disposable. New repo
20
+ vs. importing an existing one is the other fork the form covers; it derives the repo URL for new ones.
21
+
22
+ A request often implies **more than one app** (a frontend *and* its API). Create them one at a time
23
+ in the same namespace, named so the pair reads as a set — there is no batch-create, and that's fine:
24
+ two focused creates are clearer than one opaque one.
25
+
26
+ ## 2. Get it running before you build on it
27
+
28
+ Deploy the boilerplate to a real scope *first*, so the next step is "add features to a running app,"
29
+ not "debug the platform and my code at once." Create the target environment with
30
+ `managing-environments`, then ship the skeleton with `deploying-safely` (first-ever-deploy semantics
31
+ live there). Real environments are usually **policy-gated** — a production scope or deploy may land
32
+ in `creating_approval`; that's normal, surface it and don't fight it, keep moving on what isn't gated.
33
+
34
+ ## 3. Wire the data it needs
35
+
36
+ A new product usually needs a dependency — a SQL database, a queue, a cache. `application_service_list`
37
+ shows what's already attached and the catalog available; `application_service_create` provisions one
38
+ and autolinks it. Provisioning creates **real cloud resources (cost-bearing)** and runs
39
+ asynchronously, so it is **form-first**: call it to open the form, let the user fill the parameters
40
+ and submit (the submit is the cost confirmation) — never pass `provision:true` unprompted. Target the
41
+ link at the scope that needs the data. Don't call it "ready" until the service is active and linked.
42
+
43
+ Once a service is linked, its connection details arrive **automatically as read-only parameters**
44
+ (`DATABASE_URL`, host, credentials — secrets masked). Your code reads those env vars; you never
45
+ hard-code them or re-create them by hand. New parameters only reach a running scope on the **next
46
+ deploy**.
47
+
48
+ ## 4. Hand off to the build
49
+
50
+ The platform's job is done when the app exists, runs, and its dependencies are wired and surfaced as
51
+ parameters. Writing the REST API, the UI, the schema or its codegen — that is your craft, not this
52
+ playbook's job (same boundary as `working-from-a-ticket`). Build against the injected env vars, keep
53
+ the work on a branch with the issue key (`tracing-a-change`), push so CI produces a build, and ship
54
+ it with `deploying-safely`.
55
+
56
+ ## 5. Configure and redeploy
57
+
58
+ App-level config the code expects — a page-size cap, feature flags — is a parameter, not a code
59
+ constant: set it with `application_parameter_create` (`configuring-safely` for targeting at app vs.
60
+ scope vs. dimension). Remember values apply on the **next** deploy, so redeploy to pick them up.
61
+
62
+ ## Don'ts
63
+
64
+ - Don't drop a new app in the default namespace without checking it fits the request.
65
+ - Don't write feature code before the boilerplate has a running deploy — prove the platform first.
66
+ - Don't re-create or hard-code connection details a service link already injects — read them as
67
+ parameters (they're `read_only`).
68
+ - Don't treat provisioning as instant — it's async and costs money; confirm before, and don't mark
69
+ data "ready" until the service is active, linked, and a redeploy has surfaced its params.
70
+ - Don't block the whole product on one approval — production gates are routine; surface them and
71
+ keep moving on everything that isn't gated.