@nullplatform/mcp 0.1.3 → 0.1.4

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 CHANGED
@@ -51,6 +51,9 @@ const english = {
51
51
  "header.live": "Live",
52
52
  "header.traffic": "Traffic",
53
53
  "header.when": "When",
54
+ "header.version": "Version",
55
+ "header.build": "Build",
56
+ "header.release": "Release",
54
57
  // — markdown design language —
55
58
  "md.next": "Next",
56
59
  "md.none": "_none_",
@@ -168,6 +171,16 @@ const english = {
168
171
  "builds.assetsTitle": "Assets of build #{build}",
169
172
  "builds.noAssets": "Build #{build} has no assets (it may still be building).",
170
173
  "builds.errorLabel": "Couldn't list builds",
174
+ "releaseList.title": "**{app}** · {count} release(s)",
175
+ "releaseList.none": "**{app}** has no releases yet.",
176
+ "releaseList.noneHint": "cut one from a successful build with `application_release_create`, or just `application_deployment_create`.",
177
+ "releaseList.deployHint": 'deploy an active release with `application_deployment_create version:"x.y.z"`.',
178
+ "releaseList.errorLabel": "Couldn't list releases",
179
+ "deploymentList.title": "**{app}** · {count} deployment(s)",
180
+ "deploymentList.none": "**{app}** has no deployments yet.",
181
+ "deploymentList.group": "{count} scopes (group)",
182
+ "deploymentList.hint": "`application_get` for the live picture, or `application_deployment_update` to drive a rollout.",
183
+ "deploymentList.errorLabel": "Couldn't list deployments",
171
184
  // — organization_get tool —
172
185
  "overview.title": "Organization overview · {count} application(s) scanned",
173
186
  "overview.truncated": '_(showing the first {count} — narrow with `organization_get query:"..."`)_',
@@ -325,6 +338,9 @@ const spanish = {
325
338
  "header.live": "En vivo",
326
339
  "header.traffic": "Tráfico",
327
340
  "header.when": "Cuándo",
341
+ "header.version": "Versión",
342
+ "header.build": "Build",
343
+ "header.release": "Release",
328
344
  "md.next": "Siguiente",
329
345
  "md.none": "_ninguno_",
330
346
  "md.justNow": "recién",
@@ -432,6 +448,16 @@ const spanish = {
432
448
  "builds.assetsTitle": "Assets del build #{build}",
433
449
  "builds.noAssets": "El build #{build} no tiene assets (puede estar compilando todavía).",
434
450
  "builds.errorLabel": "No pude listar los builds",
451
+ "releaseList.title": "**{app}** · {count} release(s)",
452
+ "releaseList.none": "**{app}** todavía no tiene releases.",
453
+ "releaseList.noneHint": "cortá uno desde un build exitoso con `application_release_create`, o directamente `application_deployment_create`.",
454
+ "releaseList.deployHint": 'deployá un release activo con `application_deployment_create version:"x.y.z"`.',
455
+ "releaseList.errorLabel": "No pude listar los releases",
456
+ "deploymentList.title": "**{app}** · {count} deployment(s)",
457
+ "deploymentList.none": "**{app}** todavía no tiene deployments.",
458
+ "deploymentList.group": "{count} scopes (grupo)",
459
+ "deploymentList.hint": "`application_get` para la foto en vivo, o `application_deployment_update` para conducir un rollout.",
460
+ "deploymentList.errorLabel": "No pude listar los deployments",
435
461
  // — organization_get tool —
436
462
  "overview.title": "Panorama de la organización · {count} aplicación(es) escaneada(s)",
437
463
  "overview.truncated": '_(muestro las primeras {count} — acotá con `organization_get query:"..."`)_',
@@ -26,6 +26,8 @@ export async function listBuilds(np, applicationId, options = {}) {
26
26
  // canonical OpenAPI contract) — this is how a change is traced from a commit to its build.
27
27
  if (options.commit)
28
28
  query["commit.id"] = options.commit;
29
+ if (options.offset)
30
+ query.offset = options.offset;
29
31
  const page = await np.get("/build", query).catch(() => ({ results: [] }));
30
32
  return (page.results ?? []).map((build) => ({
31
33
  id: build.id,
@@ -52,6 +54,8 @@ export async function listReleases(np, applicationId, options = {}) {
52
54
  };
53
55
  if (options.status)
54
56
  query.status = options.status;
57
+ if (options.offset)
58
+ query.offset = options.offset;
55
59
  const page = await np
56
60
  .get("/release", query)
57
61
  .catch(() => ({ results: [] }));
@@ -195,6 +199,25 @@ export async function listScopeDeployments(np, scopeId, limit = 3) {
195
199
  .catch(() => ({ results: [] }));
196
200
  return (page.results ?? []).map(mapDeployment);
197
201
  }
202
+ /** Every deployment of an application, newest first — the public `/deployment` endpoint accepts
203
+ * `application_id`. Group rows (`type: "deployment_group"`) carry their per-scope children. */
204
+ export async function listDeployments(np, applicationId, options = {}) {
205
+ const query = {
206
+ application_id: applicationId,
207
+ sort: "created_at:desc",
208
+ limit: options.limit ?? 50,
209
+ };
210
+ if (options.offset)
211
+ query.offset = options.offset;
212
+ const page = await np
213
+ .get("/deployment", query)
214
+ .catch(() => ({ results: [] }));
215
+ return (page.results ?? []).map((row) => ({
216
+ ...mapDeployment(row),
217
+ type: row.type,
218
+ deployments: row.deployments?.map(mapDeployment),
219
+ }));
220
+ }
198
221
  /**
199
222
  * Traffic switch — the public API takes desiredSwitchedTraffic (camelCase INSIDE the
200
223
  * snake_case strategy_data envelope; verified live) and moves traffic toward it.
@@ -243,9 +266,12 @@ export async function setParameters(np, nrn, params) {
243
266
  return { created, updated };
244
267
  }
245
268
  /** Read parameters — NRN-scoped on the public API; returns undefined when unavailable. */
246
- export async function listParameters(np, nrn) {
269
+ export async function listParameters(np, nrn, options = {}) {
247
270
  try {
248
- const page = await np.get("/parameter", { nrn, limit: 100 });
271
+ const query = { nrn, limit: options.limit ?? 100 };
272
+ if (options.offset)
273
+ query.offset = options.offset;
274
+ const page = await np.get("/parameter", query);
249
275
  return (page.results ?? []).map((parameter) => ({
250
276
  id: parameter.id,
251
277
  name: parameter.name,
@@ -12,6 +12,8 @@ export const TOOL = {
12
12
  applicationLogList: "application_log_list",
13
13
  applicationMetricList: "application_metric_list",
14
14
  applicationBuildList: "application_build_list",
15
+ applicationReleaseList: "application_release_list",
16
+ applicationDeploymentList: "application_deployment_list",
15
17
  applicationParameterList: "application_parameter_list",
16
18
  applicationParameterCreate: "application_parameter_create",
17
19
  applicationDeploymentCreate: "application_deployment_create",
@@ -4,7 +4,7 @@ import { ago, glyph, next, shortCommit, table } from "../md.js";
4
4
  import { listAssets, listBuilds, listReleases } from "../np/journey.js";
5
5
  import { defineTool, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
- import { appArg, requireApp } from "./shared.js";
7
+ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
8
8
  /**
9
9
  * Closes the hole in the ship loop: between "push a commit" and "deploy" sits CI, and the
10
10
  * agent needs to see builds land. status only shows the latest; this lists recent builds,
@@ -15,6 +15,7 @@ export const buildsTool = defineTool({
15
15
  title: "Builds",
16
16
  description: "List an application's recent CI builds — status, branch, commit, age, and whether each is already released. Use it to answer 'did my push build yet?' and to pick a build_id to deploy. Pass build:<id> to see that build's assets, or commit:<sha> to find the build for a specific commit (tracing a change to its build and release).",
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
+ widget: "builds",
18
19
  errorKey: "builds.errorLabel",
19
20
  inputSchema: {
20
21
  app: appArg,
@@ -24,6 +25,7 @@ export const buildsTool = defineTool({
24
25
  .string()
25
26
  .optional()
26
27
  .describe("Full commit SHA — list only the build(s) for that commit (tracing a change to its build/release)"),
28
+ offset: offsetArg,
27
29
  },
28
30
  async handler(args, context) {
29
31
  const resolved = await requireApp(context, args);
@@ -47,7 +49,7 @@ export const buildsTool = defineTool({
47
49
  return reply(markdown, { build_id: args.build, assets });
48
50
  }
49
51
  const [builds, releases] = await Promise.all([
50
- listBuilds(context.np, app.id, { limit: args.limit ?? 10, commit: args.commit }),
52
+ listBuilds(context.np, app.id, { limit: args.limit ?? 10, commit: args.commit, offset: args.offset }),
51
53
  listReleases(context.np, app.id, { limit: 50 }),
52
54
  ]);
53
55
  if (builds.length === 0) {
@@ -86,6 +88,7 @@ export const buildsTool = defineTool({
86
88
  released: releasedBuildIds.has(build.id),
87
89
  release_semver: releaseByBuild.get(build.id)?.semver ?? null,
88
90
  })),
91
+ page: pageOf(builds.length, args.limit ?? 10, args.offset ?? 0),
89
92
  });
90
93
  },
91
94
  });
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { ago, glyph, next, table } from "../md.js";
4
+ import { listDeployments, listReleases, listScopes } from "../np/journey.js";
5
+ import { defineTool, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
8
+ /**
9
+ * The deployments list — the end of build → release → deploy. Lists every deployment across an
10
+ * app's scopes (a deployment_group lands one release on several scopes and is shown as one row),
11
+ * resolving scope and release names so both the text reply and the bound widget read cleanly.
12
+ */
13
+ export const deploymentsTool = defineTool({
14
+ name: TOOL.applicationDeploymentList,
15
+ title: "Deployments",
16
+ description: "List an application's deployments across all scopes — which release landed on which scope, the rollout status, and age; a deployment group (one release to several scopes) is shown as one row. Use to see deploy history or find an active rollout.",
17
+ annotations: { readOnlyHint: true, openWorldHint: true },
18
+ widget: "deployments",
19
+ errorKey: "deploymentList.errorLabel",
20
+ inputSchema: {
21
+ app: appArg,
22
+ limit: z.number().optional().describe("How many recent deployments (default 50)"),
23
+ offset: offsetArg,
24
+ },
25
+ async handler(args, context) {
26
+ const resolved = await requireApp(context, args);
27
+ if ("out" in resolved)
28
+ return resolved.out;
29
+ const app = resolved.app;
30
+ const [rows, scopes, releases] = await Promise.all([
31
+ listDeployments(context.np, app.id, { limit: args.limit ?? 50, offset: args.offset }),
32
+ listScopes(context.np, app.id),
33
+ listReleases(context.np, app.id, { limit: 200 }),
34
+ ]);
35
+ if (rows.length === 0) {
36
+ return reply(translate("deploymentList.none", { app: app.name }), {
37
+ app: `#${app.id}`,
38
+ app_name: app.name,
39
+ deployments: [],
40
+ });
41
+ }
42
+ const scopeName = new Map(scopes.map((scope) => [scope.id, scope.name]));
43
+ const semverOf = new Map(releases.map((release) => [release.id, release.semver]));
44
+ const scopeLabel = (row) => row.type === "deployment_group"
45
+ ? translate("deploymentList.group", { count: row.deployments?.length ?? 0 })
46
+ : (scopeName.get(row.scope_id ?? -1) ?? `scope #${row.scope_id ?? "?"}`);
47
+ const releaseLabel = (releaseId) => releaseId ? (semverOf.get(releaseId) ?? `#${releaseId}`) : "";
48
+ const markdown = [
49
+ translate("deploymentList.title", { app: app.name, count: rows.length }),
50
+ "",
51
+ table([
52
+ translate("header.scope"),
53
+ translate("header.release"),
54
+ translate("header.status"),
55
+ translate("header.when"),
56
+ ], rows.map((row) => [
57
+ scopeLabel(row),
58
+ releaseLabel(row.release_id),
59
+ `${glyph(row.status)} ${row.status}`,
60
+ ago(row.created_at),
61
+ ])),
62
+ next(translate("deploymentList.hint")),
63
+ ].join("\n");
64
+ // Enrich the structured rows the widget renders: scope + release names resolved, children too.
65
+ const enrich = (row) => ({
66
+ ...row,
67
+ scope_name: scopeName.get(row.scope_id ?? -1) ?? null,
68
+ release_semver: row.release_id ? (semverOf.get(row.release_id) ?? null) : null,
69
+ });
70
+ const deployments = rows.map((row) => ({
71
+ ...enrich(row),
72
+ deployments: row.deployments?.map(enrich),
73
+ }));
74
+ return reply(markdown, {
75
+ app: `#${app.id}`,
76
+ app_name: app.name,
77
+ deployments,
78
+ page: pageOf(rows.length, args.limit ?? 50, args.offset ?? 0),
79
+ });
80
+ },
81
+ });
@@ -4,12 +4,14 @@ import { createAppTool } from "./create-app.js";
4
4
  import { createReleaseTool } from "./create-release.js";
5
5
  import { createScopeTool } from "./create-scope.js";
6
6
  import { deployTool } from "./deploy.js";
7
+ import { deploymentsTool } from "./deployments.js";
7
8
  import { findAppsTool } from "./find-apps.js";
8
9
  import { logsTool } from "./logs.js";
9
10
  import { metricsTool } from "./metrics.js";
10
11
  import { overviewTool } from "./overview.js";
11
12
  import { paramsTool } from "./params.js";
12
13
  import { playbookGetTool } from "./playbook.js";
14
+ import { releasesTool } from "./releases.js";
13
15
  import { servicesTool } from "./services.js";
14
16
  import { setParamsTool } from "./set-params.js";
15
17
  import { statusTool } from "./status.js";
@@ -24,6 +26,8 @@ export const tools = [
24
26
  overviewTool,
25
27
  findAppsTool,
26
28
  buildsTool,
29
+ releasesTool,
30
+ deploymentsTool,
27
31
  logsTool,
28
32
  paramsTool,
29
33
  metricsTool,
@@ -3,7 +3,7 @@ import { dashboardLink, linkLine, next, table } from "../md.js";
3
3
  import { listParameters } from "../np/journey.js";
4
4
  import { defineTool, fail, reply } from "../tool.js";
5
5
  import { TOOL } from "../tool-names.js";
6
- import { appArg, requireApp } from "./shared.js";
6
+ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
7
7
  export const paramsTool = defineTool({
8
8
  name: TOOL.applicationParameterList,
9
9
  title: "Parameters",
@@ -11,7 +11,7 @@ export const paramsTool = defineTool({
11
11
  annotations: { readOnlyHint: true, openWorldHint: true },
12
12
  widget: "params",
13
13
  errorKey: "params.errorLabel",
14
- inputSchema: { app: appArg },
14
+ inputSchema: { app: appArg, offset: offsetArg },
15
15
  async handler(args, context) {
16
16
  const resolved = await requireApp(context, args);
17
17
  if ("out" in resolved)
@@ -20,7 +20,7 @@ export const paramsTool = defineTool({
20
20
  if (!app.nrn)
21
21
  return fail(translate("resolve.noNrn", { app: app.name }));
22
22
  const [parameters, orgSlug] = await Promise.all([
23
- listParameters(context.np, app.nrn),
23
+ listParameters(context.np, app.nrn, { offset: args.offset }),
24
24
  context.org.organizationSlug(),
25
25
  ]);
26
26
  const dashboard = dashboardLink(orgSlug, app.nrn);
@@ -53,6 +53,11 @@ export const paramsTool = defineTool({
53
53
  ])),
54
54
  next(translate("params.applyHint")),
55
55
  ].join("\n");
56
- return reply(markdown, { available: true, params: parameters, ...appRef });
56
+ return reply(markdown, {
57
+ available: true,
58
+ params: parameters,
59
+ page: pageOf(parameters.length, 100, args.offset ?? 0),
60
+ ...appRef,
61
+ });
57
62
  },
58
63
  });
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { ago, glyph, next, table } from "../md.js";
4
+ import { listReleases } from "../np/journey.js";
5
+ import { defineTool, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
8
+ /**
9
+ * The releases list — the middle of build → release → deploy. A release is an immutable semver
10
+ * pointer to a build; this lists them so the model (and the bound widget) can pick one to deploy.
11
+ */
12
+ export const releasesTool = defineTool({
13
+ name: TOOL.applicationReleaseList,
14
+ title: "Releases",
15
+ description: "List an application's releases — semver, status (active/deprecated/unstable/failed), the build each pins, and age. Use to pick a release to deploy or promote, or to see what's shippable.",
16
+ annotations: { readOnlyHint: true, openWorldHint: true },
17
+ widget: "releases",
18
+ errorKey: "releaseList.errorLabel",
19
+ inputSchema: {
20
+ app: appArg,
21
+ limit: z.number().optional().describe("How many recent releases (default 25)"),
22
+ offset: offsetArg,
23
+ },
24
+ async handler(args, context) {
25
+ const resolved = await requireApp(context, args);
26
+ if ("out" in resolved)
27
+ return resolved.out;
28
+ const app = resolved.app;
29
+ const releases = await listReleases(context.np, app.id, {
30
+ limit: args.limit ?? 25,
31
+ offset: args.offset,
32
+ });
33
+ if (releases.length === 0) {
34
+ return reply(translate("releaseList.none", { app: app.name }) + next(translate("releaseList.noneHint")), { app: `#${app.id}`, app_name: app.name, releases: [] });
35
+ }
36
+ const markdown = [
37
+ translate("releaseList.title", { app: app.name, count: releases.length }),
38
+ "",
39
+ table([
40
+ translate("header.version"),
41
+ translate("header.status"),
42
+ translate("header.build"),
43
+ translate("header.when"),
44
+ ], releases.map((release) => [
45
+ release.semver,
46
+ `${glyph(release.status)} ${release.status}`,
47
+ release.build_id ? `#${release.build_id}` : "",
48
+ ago(release.created_at),
49
+ ])),
50
+ next(translate("releaseList.deployHint")),
51
+ ].join("\n");
52
+ return reply(markdown, {
53
+ app: `#${app.id}`,
54
+ app_name: app.name,
55
+ releases,
56
+ page: pageOf(releases.length, args.limit ?? 25, args.offset ?? 0),
57
+ });
58
+ },
59
+ });
@@ -11,6 +11,19 @@ export const appArg = z
11
11
  .string()
12
12
  .optional()
13
13
  .describe('Application name or "#id". Omit it to use the app linked to the current repo (git remote).');
14
+ export const offsetArg = z
15
+ .number()
16
+ .optional()
17
+ .describe("Skip this many items to page through a long list (use page.next_offset from a prior call).");
18
+ /**
19
+ * Pagination token for a list reply. No list endpoint returns a total, so the boundary is the
20
+ * standard offset heuristic: a full page means there is likely more. The widget/model pages by
21
+ * re-calling the tool with `offset: next_offset` until `has_more` is false.
22
+ */
23
+ export function pageOf(count, limit, offset = 0) {
24
+ const hasMore = count >= limit;
25
+ return { has_more: hasMore, next_offset: hasMore ? offset + limit : null };
26
+ }
14
27
  export function httpsRepoUrl(url) {
15
28
  const trimmed = url.trim().replace(/\.git$/, "");
16
29
  const sshForm = /^git@([^:]+):(.+)$/.exec(trimmed);
package/dist/ui.js CHANGED
@@ -19,6 +19,9 @@ export const WIDGETS = {
19
19
  logs: "Logs",
20
20
  "find-apps": "Applications",
21
21
  metrics: "Metrics",
22
+ builds: "Builds",
23
+ releases: "Releases",
24
+ deployments: "Deployments",
22
25
  };
23
26
  /** Did this session's client negotiate the MCP Apps extension? (Knowable after initialize.) */
24
27
  export function uiNegotiated(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",