@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.
- package/dist/i18n.js +206 -22
- package/dist/np/client.js +3 -0
- package/dist/np/journey.js +236 -24
- package/dist/tool-names.js +7 -0
- package/dist/tools/action-flow.js +94 -0
- package/dist/tools/approvals.js +2 -2
- package/dist/tools/builds.js +23 -6
- package/dist/tools/create-link.js +163 -0
- package/dist/tools/create-service.js +149 -0
- package/dist/tools/delete-flow.js +30 -0
- package/dist/tools/delete-link.js +95 -0
- package/dist/tools/delete-param.js +76 -0
- package/dist/tools/delete-service.js +108 -0
- package/dist/tools/deployments.js +2 -2
- package/dist/tools/index.js +14 -0
- package/dist/tools/logs.js +2 -2
- package/dist/tools/metrics.js +4 -1
- package/dist/tools/overview.js +2 -2
- package/dist/tools/params.js +78 -17
- package/dist/tools/playbook.js +2 -1
- package/dist/tools/releases.js +2 -2
- package/dist/tools/services.js +2 -2
- package/dist/tools/set-params.js +61 -11
- package/dist/tools/shared.js +4 -0
- package/dist/tools/update-link.js +87 -0
- package/dist/tools/update-service.js +92 -0
- package/dist/ui.js +4 -1
- package/package.json +3 -1
- package/skills/starting-a-new-app/SKILL.md +71 -0
- package/widgets-dist/approvals.html +261 -57
- package/widgets-dist/builds.html +256 -52
- package/widgets-dist/create-app.html +252 -48
- package/widgets-dist/deployments.html +251 -47
- package/widgets-dist/find-apps.html +251 -47
- package/widgets-dist/logs.html +256 -52
- package/widgets-dist/manifest.json +16 -13
- package/widgets-dist/metrics.html +261 -57
- package/widgets-dist/np-panel.html +256 -52
- package/widgets-dist/overview.html +256 -52
- package/widgets-dist/params.html +257 -53
- package/widgets-dist/releases.html +257 -53
- package/widgets-dist/service-action.html +1118 -0
- package/widgets-dist/service-create.html +1117 -0
- package/widgets-dist/{playbook.html → service-delete.html} +258 -58
- package/widgets-dist/service-link.html +1117 -0
- package/widgets-dist/services.html +249 -45
package/dist/tools/params.js
CHANGED
|
@@ -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 [
|
|
23
|
-
|
|
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
|
-
|
|
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.
|
|
112
|
+
translate("header.values"),
|
|
47
113
|
], parameters.map((parameter) => [
|
|
48
114
|
parameter.name,
|
|
49
|
-
parameter.variable ?? "",
|
|
50
|
-
parameter.type ?? "
|
|
115
|
+
parameter.variable ?? parameter.destination_path ?? "",
|
|
116
|
+
parameter.type ?? "environment",
|
|
51
117
|
parameter.secret ? "🔒" : "",
|
|
52
|
-
parameter
|
|
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
|
});
|
package/dist/tools/playbook.js
CHANGED
|
@@ -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:
|
|
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.'),
|
package/dist/tools/releases.js
CHANGED
|
@@ -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
|
-
|
|
37
|
+
plural(releases.length, "releaseList.title.one", "releaseList.title.many", { app: app.name }),
|
|
38
38
|
"",
|
|
39
39
|
table([
|
|
40
40
|
translate("header.version"),
|
package/dist/tools/services.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/tools/set-params.js
CHANGED
|
@@ -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
|
|
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,
|
|
35
|
-
const names = args.params.map((
|
|
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
|
-
?
|
|
77
|
+
? plural(result.created, "setParams.created.one", "setParams.created.many", {
|
|
78
|
+
app: app.name,
|
|
79
|
+
names,
|
|
80
|
+
})
|
|
40
81
|
: result.created === 0
|
|
41
|
-
?
|
|
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
|
|
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
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|