@nullplatform/mcp 0.1.14 → 0.1.15

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 (42) hide show
  1. package/README.md +10 -0
  2. package/dist/http.js +16 -12
  3. package/dist/i18n.js +6 -0
  4. package/dist/log.js +53 -0
  5. package/dist/np/client.js +10 -1
  6. package/dist/np/context.js +24 -19
  7. package/dist/np/journey.js +14 -4
  8. package/dist/render.js +12 -13
  9. package/dist/surfaces/developer.js +14 -4
  10. package/dist/tool.js +64 -6
  11. package/dist/tools/create-app.js +125 -111
  12. package/dist/tools/create-link.js +81 -54
  13. package/dist/tools/create-scope.js +34 -22
  14. package/dist/tools/create-service.js +38 -24
  15. package/dist/tools/deploy.js +51 -29
  16. package/dist/tools/entity-list.js +14 -6
  17. package/dist/tools/logs.js +114 -17
  18. package/dist/tools/overview.js +1 -1
  19. package/dist/tools/params.js +7 -1
  20. package/dist/tools/set-params.js +17 -18
  21. package/dist/tools/shared.js +9 -0
  22. package/dist/tools/status.js +4 -4
  23. package/dist/tools/traffic.js +38 -26
  24. package/dist/ui.js +1 -1
  25. package/package.json +2 -1
  26. package/widgets-dist/approvals.html +112 -10
  27. package/widgets-dist/builds.html +119 -17
  28. package/widgets-dist/create-app.html +121 -19
  29. package/widgets-dist/deployments.html +124 -22
  30. package/widgets-dist/find-apps.html +123 -21
  31. package/widgets-dist/logs.html +125 -23
  32. package/widgets-dist/manifest.json +16 -16
  33. package/widgets-dist/metrics.html +112 -10
  34. package/widgets-dist/overview.html +128 -26
  35. package/widgets-dist/params.html +119 -17
  36. package/widgets-dist/releases.html +128 -26
  37. package/widgets-dist/service-action.html +116 -14
  38. package/widgets-dist/service-create.html +115 -13
  39. package/widgets-dist/service-delete.html +112 -10
  40. package/widgets-dist/service-link.html +115 -13
  41. package/widgets-dist/services.html +112 -10
  42. package/widgets-dist/{np-panel.html → status.html} +127 -25
@@ -79,12 +79,54 @@ async function resolveRelease(context, applicationId, args) {
79
79
  return { release: releases[0], note: "" };
80
80
  return { out: fail(translate("deploy.nothing") + next(translate("deploy.nothingHint"))) };
81
81
  }
82
+ /**
83
+ * Resolve which scope to deploy to. Mirrors requireApp's shape: returns the picked scope, or an
84
+ * `out` reply — pickScope's own ask/fail, except a brand-new app with NO scopes gets a "create a
85
+ * scope first" guide listing the available scope types (the literal next move).
86
+ */
87
+ async function resolveDeployScope(context, app, scopeHint) {
88
+ const scopes = await listScopes(context.np, app.id);
89
+ const picked = pickScope(scopes, scopeHint);
90
+ if ("scope" in picked)
91
+ return { scope: picked.scope };
92
+ if (scopes.length === 0 && app.nrn) {
93
+ const scopeTypes = await listScopeTypes(context.np, app.nrn);
94
+ if (scopeTypes.length) {
95
+ const firstType = scopeTypes[0]?.type ?? scopeTypes[0]?.name ?? "";
96
+ const markdown = translate("deploy.noScopeTypes", {
97
+ types: scopeTypes.map((scopeType) => `**${scopeType.name}**`).join(", "),
98
+ }) + next(translate("deploy.createScopeHint", { type: firstType }));
99
+ return { out: fail(markdown, { scope_types: scopeTypes }) };
100
+ }
101
+ }
102
+ return { out: picked.out };
103
+ }
104
+ /**
105
+ * Resolve which asset to ship. An explicit `asset` wins; otherwise auto-pick by the scope's type.
106
+ * Mirrors requireApp's shape: returns the asset name (undefined when the build has none to name),
107
+ * or an `out` reply — the pick-asset panel when a multi-asset build can't be disambiguated.
108
+ */
109
+ async function resolveDeployAsset(context, release, scope, assetHint) {
110
+ if (assetHint)
111
+ return { assetName: assetHint };
112
+ const buildId = release.build_id ?? (await getRelease(context.np, release.id).catch(() => release)).build_id;
113
+ if (!buildId)
114
+ return { assetName: undefined };
115
+ const assets = await listAssets(context.np, buildId);
116
+ const choice = pickAsset(assets, scope.type);
117
+ if ("ambiguous" in choice) {
118
+ return {
119
+ out: reply(`${translate("deploy.multiAsset", { build: buildId, scope: scope.name, type: scope.type ?? "?" })}\n\n${table([translate("header.asset"), translate("header.type"), translate("header.platform")], choice.ambiguous.map((asset) => [asset.name, asset.type, asset.platform ?? ""]))}${next(translate("deploy.assetHint", { name: choice.ambiguous[0]?.name ?? "" }))}`, { assets: choice.ambiguous }),
120
+ };
121
+ }
122
+ return { assetName: choice.name };
123
+ }
82
124
  export const deployTool = defineTool({
83
125
  name: TOOL.applicationDeploymentCreate,
84
126
  title: "Deploy",
85
127
  description: 'DEPLOY an application — provisions real infrastructure (starts a rollout), rendered as the live rollout panel. With no extra args it ships the latest code: picks the latest successful build, cuts the release for you (semver auto-bumped) if needed, and targets the app\'s only scope. Use it for "deploy", "ship it", "release to dev/prod". Narrow the target with `scope` (name or "environment=production"); pin an exact `release_id` or `version` (a known version rolls FORWARD or BACK to it); or cut from a specific `build_id`. For an app that splits traffic, FOLLOW with application_deployment_update to walk traffic to the new version; to undo, application_deployment_update action:"rollback". Convergent under retries: re-running returns the in-flight rollout for the same scope+release rather than creating a duplicate.',
86
128
  annotations: { destructiveHint: false, openWorldHint: true },
87
- widget: "np-panel",
129
+ widget: "status",
88
130
  errorKey: "deploy.errorLabel",
89
131
  // The platform reports a missing/incompatible asset choice as a cross-app mismatch.
90
132
  onError: (message) => /different applications/i.test(message) ? fail(translate("deploy.rejected", { message })) : undefined,
@@ -111,39 +153,19 @@ export const deployTool = defineTool({
111
153
  if ("out" in resolved)
112
154
  return resolved.out;
113
155
  const app = resolved.app;
114
- const scopes = await listScopes(context.np, app.id);
115
- const picked = pickScope(scopes, args.scope);
116
- if ("out" in picked) {
117
- if (scopes.length === 0 && app.nrn) {
118
- const scopeTypes = await listScopeTypes(context.np, app.nrn);
119
- if (scopeTypes.length) {
120
- const firstType = scopeTypes[0]?.type ?? scopeTypes[0]?.name ?? "";
121
- const markdown = translate("deploy.noScopeTypes", {
122
- types: scopeTypes.map((scopeType) => `**${scopeType.name}**`).join(", "),
123
- }) + next(translate("deploy.createScopeHint", { type: firstType }));
124
- return fail(markdown, { scope_types: scopeTypes });
125
- }
126
- }
127
- return picked.out;
128
- }
129
- const scope = picked.scope;
156
+ const scopeResult = await resolveDeployScope(context, app, args.scope);
157
+ if ("out" in scopeResult)
158
+ return scopeResult.out;
159
+ const scope = scopeResult.scope;
130
160
  const shipping = await resolveRelease(context, app.id, args);
131
161
  if ("out" in shipping)
132
162
  return shipping.out;
133
163
  const { release, note } = shipping;
134
164
  // Multi-asset builds must name the asset to ship; the platform's error otherwise is misleading.
135
- let assetName = args.asset;
136
- if (!assetName) {
137
- const buildId = release.build_id ?? (await getRelease(context.np, release.id).catch(() => release)).build_id;
138
- if (buildId) {
139
- const assets = await listAssets(context.np, buildId);
140
- const choice = pickAsset(assets, scope.type);
141
- if ("ambiguous" in choice) {
142
- return reply(`${translate("deploy.multiAsset", { build: buildId, scope: scope.name, type: scope.type ?? "?" })}\n\n${table([translate("header.asset"), translate("header.type"), translate("header.platform")], choice.ambiguous.map((asset) => [asset.name, asset.type, asset.platform ?? ""]))}${next(translate("deploy.assetHint", { name: choice.ambiguous[0]?.name ?? "" }))}`, { assets: choice.ambiguous });
143
- }
144
- assetName = choice.name;
145
- }
146
- }
165
+ const assetResult = await resolveDeployAsset(context, release, scope, args.asset);
166
+ if ("out" in assetResult)
167
+ return assetResult.out;
168
+ const assetName = assetResult.assetName;
147
169
  const orgSlug = await context.org.organizationSlug();
148
170
  // Convergent under retries: if this exact release is already rolling out on this scope
149
171
  // (a non-terminal deployment), return that rollout instead of starting a second one.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getApplication, getScope, listApprovals, listEntity, listLinks, listParameters, listServices, } from "../np/journey.js";
2
+ import { getApplication, getScope, listApprovals, listEntity, listLinks, listParameters, listServices, listTemplates, } from "../np/journey.js";
3
3
  import { defineTool, fail, reply } from "../tool.js";
4
4
  import { TOOL } from "../tool-names.js";
5
5
  /**
@@ -22,8 +22,9 @@ const REST_ENTITIES = [
22
22
  "release",
23
23
  "deployment",
24
24
  ];
25
- /** NRN-addressed collections — listed by the parent's NRN (resolved from parent_id, or given as nrn). */
26
- const NRN_ENTITIES = ["parameter", "service", "link", "approval"];
25
+ /** NRN-addressed collections — listed by the parent's NRN (resolved from parent_id, or given as nrn).
26
+ * `template` is scoped by a NAMESPACE's NRN (the scaffolding templates available to a new app there). */
27
+ const NRN_ENTITIES = ["parameter", "service", "link", "approval", "template"];
27
28
  const ENTITIES = [...REST_ENTITIES, ...NRN_ENTITIES];
28
29
  /** The parents a collection can hang under — its id (REST) or its NRN (nrn-scoped) scopes the list. */
29
30
  const PARENTS = ["organization", "account", "namespace", "application", "scope"];
@@ -38,6 +39,11 @@ async function resolveNrn(context, args) {
38
39
  return (await getApplication(context.np, args.parent_id)).nrn;
39
40
  if (args.parent_type === "scope")
40
41
  return (await getScope(context.np, args.parent_id)).nrn;
42
+ if (args.parent_type === "namespace") {
43
+ // Templates hang off a namespace; resolve its NRN from the cached org skeleton (no extra fetch).
44
+ const skeleton = await context.org.getSkeleton();
45
+ return skeleton.namespaces.find((namespace) => namespace.id === args.parent_id)?.nrn;
46
+ }
41
47
  return undefined;
42
48
  }
43
49
  /** List an nrn-scoped resource via its existing typed function (a different addressing scheme to REST). */
@@ -51,6 +57,8 @@ async function listByNrn(context, entity, nrn) {
51
57
  return listLinks(context.np, nrn);
52
58
  case "approval":
53
59
  return listApprovals(context.bff, nrn);
60
+ case "template":
61
+ return listTemplates(context.np, nrn);
54
62
  default:
55
63
  return [];
56
64
  }
@@ -58,14 +66,14 @@ async function listByNrn(context, entity, nrn) {
58
66
  export const entityListTool = defineTool({
59
67
  name: TOOL.entityList,
60
68
  title: "List entities (data)",
61
- description: 'List releases, builds, deployments, scopes, applications, namespaces, parameters, services, links or approvals as DATA — returns structuredContent and renders NO panel. THIS is the tool for AGGREGATING or COMPARING across many apps/scopes: "the latest release across all my apps", "which app has a failing build", "every scope on the newest build" — list each app\'s releases/builds with this (no panel per app), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list whenever you are gathering to reason or to answer an aggregate — those panel reads are ONLY for showing ONE app\'s list to the user. Scoped to a parent: REST tree organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"release" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. For one entity by id use entity_get.',
69
+ description: 'List releases, builds, deployments, scopes, applications, namespaces, parameters, services, links or approvals as DATA — returns structuredContent and renders NO panel. THIS is the tool for AGGREGATING or COMPARING across many apps/scopes: "the latest release across all my apps", "which app has a failing build", "every scope on the newest build" — list each app\'s releases/builds with this (no panel per app), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list whenever you are gathering to reason or to answer an aggregate — those panel reads are ONLY for showing ONE app\'s list to the user. Scoped to a parent: REST tree organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"release" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. `template` is scoped by a NAMESPACE: pass entity:"template" parent_type:"namespace" parent_id:<namespace_id> to list the scaffolding templates available for a new app there — do this to choose a template BEFORE opening the application_create form, so the form opens with it pre-selected (don\'t open the form blind to discover templates). For one entity by id use entity_get.',
62
70
  annotations: { readOnlyHint: true },
63
71
  // No widget on purpose: data-only navigation. See CLAUDE.md (the data-only read pattern).
64
72
  errorKey: "entityList.errorLabel",
65
73
  inputSchema: {
66
74
  entity: z
67
75
  .enum(ENTITIES)
68
- .describe("Collection to list. REST tree: account, namespace, application, scope, build, release, deployment. NRN-scoped: parameter, service, link, approval."),
76
+ .describe("Collection to list. REST tree: account, namespace, application, scope, build, release, deployment. NRN-scoped: parameter, service, link, approval, template (template lists a namespace's scaffolding templates)."),
69
77
  parent_type: z
70
78
  .enum(PARENTS)
71
79
  .optional()
@@ -74,7 +82,7 @@ export const entityListTool = defineTool({
74
82
  nrn: z
75
83
  .string()
76
84
  .optional()
77
- .describe("For nrn-scoped entities (parameter/service/link/approval): the scoping NRN directly, if you have it; otherwise pass parent_type + parent_id and it's resolved for you."),
85
+ .describe('For nrn-scoped entities (parameter/service/link/approval/template): the scoping NRN directly, if you have it; otherwise pass parent_type + parent_id (for template, parent_type:"namespace") and it\'s resolved for you.'),
78
86
  limit: z.number().optional().describe("Max rows (default 100) — REST collections only."),
79
87
  },
80
88
  async handler(args, context) {
@@ -1,14 +1,27 @@
1
1
  import { z } from "zod";
2
2
  import { plural, translate } from "../i18n.js";
3
+ import { log } from "../log.js";
3
4
  import { dashboardLink, linkLine, next } from "../md.js";
4
5
  import { listScopes, readLogs } from "../np/journey.js";
5
6
  import { defineTool, fail, reply } from "../tool.js";
6
7
  import { TOOL } from "../tool-names.js";
7
8
  import { appArg, chooseScope, requireApp } from "./shared.js";
9
+ const parseMs = (value) => {
10
+ if (!value)
11
+ return 0;
12
+ const ms = new Date(value).getTime();
13
+ return Number.isNaN(ms) ? 0 : ms;
14
+ };
15
+ /** A log entry's timestamp as epoch ms, from the platform's `date` field (ISO-8601). The single time
16
+ * source for BOTH ordering and the staleness check. Absent → 0 (sorts as oldest, adds no recency).
17
+ * We never parse a time out of the message TEXT: the platform already resolves each line's time into
18
+ * `date`; an app that logs structured JSON keeps its own `"time"` inside the message, which is the
19
+ * app's payload to show verbatim, not ours to reinterpret. */
20
+ const logTimeMs = (entry) => parseMs(entry.date);
8
21
  export const logsTool = defineTool({
9
22
  name: TOOL.applicationLogList,
10
23
  title: "Logs",
11
- description: 'Read ONE application\'s recent logs (per scope), rendered as a logs PANEL — the latest lines, newest last. Use it for "why is it failing?", "show me the logs", "what errored". Logs are per-scope: omit `scope` to use the only one (or be asked which); page older lines with `page_token`; narrow the window with `start_time`/`end_time` (ISO-8601) or cap with `lines`. Does NOT return metrics or deploy status — use application_metric_list or application_get. Read-only.',
24
+ description: 'Read ONE application\'s recent logs (per scope), rendered as a logs PANEL — the latest lines, newest last. Use it for "why is it failing?", "show me the logs", "what errored", "show <app> logs". Pass `app` (name OR "#id") and this resolves the application ITSELF — call it DIRECTLY; do NOT call application_list (to find the app) or application_get (to look it up) first, that just renders panels the user didn\'t ask for. "show <app> logs" is ONE call: this tool with app:"<app>". Logs are per-scope: omit `scope` to use the only one (or be asked which). With no window the SERVER auto-widens 1m → 5m → 30m and returns the first window that has logs, then falls back to the LATEST available lines (any age) so a quiet/stopped app still shows its last logs (e.g. the crash) — it prefers recent activity but is NEVER empty, so you NEVER need to re-call with a wider window (don\'t widen client-side — one call is enough). To pin a specific range pass `start_time`/`end_time` (ISO-8601) or a bigger `lines` (an explicit window skips the auto-widen). Page older lines with `page_token`. Does NOT return metrics or deploy status — use application_metric_list or application_get. Read-only.',
12
25
  annotations: { readOnlyHint: true, openWorldHint: true },
13
26
  widget: "logs",
14
27
  errorKey: "logs.errorLabel",
@@ -46,14 +59,84 @@ export const logsTool = defineTool({
46
59
  return picked.out;
47
60
  const scope = picked.scope;
48
61
  const maxLines = args.lines ?? 50;
49
- const page = await readLogs(context.np, {
50
- application_id: app.id,
51
- scope_id: scope.id,
52
- next_page_token: args.page_token,
53
- start_time: args.start_time,
54
- end_time: args.end_time,
55
- });
56
- const entries = (page.results ?? []).slice(-maxLines);
62
+ // No explicit window → progressively WIDEN the lookback (1m → 5m → 30m → 6h → 24h → 7d), stopping
63
+ // at the first window that actually has logs. So "show logs" starts with recent activity instead
64
+ // of an empty last-minute view on a quiet app. The wider steps past 30m are NOT cosmetic: an app
65
+ // that last logged, say, 45m ago has its newest lines just OUTSIDE the 30m "live" window. Without
66
+ // a 6h step the widen would skip straight to the unfiltered fallback — and an unfiltered read can
67
+ // return an OLDER partition than the most-recent lines (observed live: 06-19 lines surfaced while
68
+ // the app's real 06-22 lines sat in the 30m→unfiltered gap). An explicit `start_time` filters
69
+ // server-side, so the 6h step returns those 06-22 lines; staleness still flags them as not-live.
70
+ // The live tail re-runs this each poll. An explicit window / `page_token` skips the widening (the
71
+ // user/model asked for a specific range; widen further yourself to diagnose an older failure).
72
+ const explicit = Boolean(args.start_time || args.end_time || args.page_token);
73
+ const minute = 60_000;
74
+ const hour = 60 * minute;
75
+ const since = (ageMs) => ({ start_time: new Date(Date.now() - ageMs).toISOString() });
76
+ const windows = explicit
77
+ ? [{ start_time: args.start_time, end_time: args.end_time, next_page_token: args.page_token }]
78
+ : [
79
+ since(minute),
80
+ since(5 * minute),
81
+ since(30 * minute),
82
+ since(6 * hour),
83
+ since(24 * hour),
84
+ since(7 * 24 * hour),
85
+ // Last resort: latest-available, ANY age — an app silent for >7d still shows its last lines
86
+ // (e.g. an old crash), NEVER an empty panel. The explicit windows above are what surface the
87
+ // REAL most-recent lines; an unfiltered read can return an older partition, so it's only the
88
+ // truly-ancient safety net, never the primary path.
89
+ {},
90
+ ];
91
+ let page = await readLogs(context.np, { application_id: app.id, scope_id: scope.id, ...windows[0] });
92
+ let usedWindow = 0;
93
+ log.debug({ logsTool: { app: app.id, scope: scope.id, window: windows[0], count: page.results?.length ?? 0 } }, "logs: window 0");
94
+ for (let attempt = 1; attempt < windows.length && !(page.results ?? []).length; attempt++) {
95
+ page = await readLogs(context.np, { application_id: app.id, scope_id: scope.id, ...windows[attempt] });
96
+ usedWindow = attempt;
97
+ log.debug({
98
+ logsTool: {
99
+ app: app.id,
100
+ scope: scope.id,
101
+ window: windows[attempt],
102
+ count: page.results?.length ?? 0,
103
+ },
104
+ }, `logs: window ${attempt}`);
105
+ }
106
+ // Sort oldest → newest by timestamp, THEN take the newest `maxLines` (kept oldest-first for the
107
+ // "newest last" display). The platform returns lines newest-first, so a naive slice(-maxLines)
108
+ // took the OLDEST lines — which is why a multi-day log set surfaced days-old lines under "live".
109
+ const ordered = (page.results ?? []).slice().sort((left, right) => logTimeMs(left) - logTimeMs(right));
110
+ const entries = ordered.slice(-maxLines);
111
+ // Staleness: with no explicit window the auto-widen tries 1m → 5m → 30m before the latest-available
112
+ // fallback. So if the NEWEST line we got is older than that widest live window (30m), the app has had
113
+ // no recent activity and these are last-gasp lines (e.g. a crash days ago). Flag it so the panel/text
114
+ // says "no recent activity, last logs from <time>" instead of presenting old lines as a live tail.
115
+ // An explicit window/page_token means the user asked for that range — old lines there aren't "stale".
116
+ const newestMs = entries.reduce((max, entry) => Math.max(max, logTimeMs(entry)), 0);
117
+ const stale = !explicit && newestMs > 0 && Date.now() - newestMs > 30 * 60_000;
118
+ const newestTs = newestMs > 0 ? new Date(newestMs).toISOString() : null;
119
+ // The trace that pins down a logs-fetch complaint: which window won, how the staleness verdict was
120
+ // reached, and a sample of the RAW entries — the `date` field each line carries (the platform's
121
+ // timestamp) plus a short message head, so a missing/odd `date` is visible in the trace.
122
+ log.debug({
123
+ logsTool: {
124
+ app: app.id,
125
+ scope: scope.name,
126
+ explicit,
127
+ usedWindow,
128
+ rawCount: page.results?.length ?? 0,
129
+ entryCount: entries.length,
130
+ newestTs,
131
+ stale,
132
+ nowIso: new Date().toISOString(),
133
+ ageMin: newestMs > 0 ? Math.round((Date.now() - newestMs) / 60_000) : null,
134
+ sample: (page.results ?? []).slice(0, 6).map((entry) => ({
135
+ date: entry.date ?? null,
136
+ msgHead: entry.message.slice(0, 80),
137
+ })),
138
+ },
139
+ }, "logs: resolved entries + staleness");
57
140
  const scopeSuffix = ` · ${scope.name}`;
58
141
  // The full scope list rides along so the widget can offer a scope switcher.
59
142
  const scopeList = scopes.map((candidate) => ({ name: candidate.name, status: candidate.status }));
@@ -64,24 +147,38 @@ export const logsTool = defineTool({
64
147
  }
65
148
  const body = entries
66
149
  .map((entry) => {
67
- const timestamp = entry.datetime ?? entry.timestamp;
68
- const prefix = timestamp
69
- ? `${new Date(timestamp).toISOString().slice(5, 19).replace("T", " ")} `
70
- : "";
71
- return `${prefix}${(entry.message ?? "").trimEnd()}`;
150
+ // Timestamp column comes from the `date` field; guard a bad value (the API could return an
151
+ // unparseable date) so the prefix is just dropped rather than throwing.
152
+ const ms = parseMs(entry.date);
153
+ const prefix = ms ? `${new Date(ms).toISOString().slice(5, 19).replace("T", " ")} ` : "";
154
+ return `${prefix}${entry.message.trimEnd()}`;
72
155
  })
73
156
  .join("\n");
74
- const markdown = `${plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", { app: app.name, scope: scopeSuffix })}\n\n\`\`\`\n${body}\n\`\`\``;
157
+ const heading = stale
158
+ ? plural(entries.length, "logs.staleLines.one", "logs.staleLines.many", {
159
+ app: app.name,
160
+ scope: scopeSuffix,
161
+ time: (newestTs ?? "").slice(0, 16).replace("T", " "),
162
+ })
163
+ : plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", {
164
+ app: app.name,
165
+ scope: scopeSuffix,
166
+ });
167
+ const markdown = `${heading}\n\n\`\`\`\n${body}\n\`\`\``;
75
168
  return reply(markdown, {
76
169
  count: entries.length,
170
+ stale,
171
+ newest_ts: newestTs,
77
172
  next_page_token: page.next_page_token ?? null,
78
173
  app: `#${app.id}`,
79
174
  app_name: app.name,
80
175
  scope: scope.name,
81
176
  scopes: scopeList,
177
+ // Two columns: the timestamp from the `date` field (null → the widget shows no timestamp cell,
178
+ // just the message), and the message verbatim. No parsing time out of the text.
82
179
  lines: entries.map((entry) => ({
83
- ts: entry.datetime ?? entry.timestamp ?? null,
84
- message: entry.message ?? "",
180
+ ts: entry.date ?? null,
181
+ message: entry.message,
85
182
  })),
86
183
  });
87
184
  },
@@ -14,7 +14,7 @@ const MAX_APPS = 30;
14
14
  export const overviewTool = defineTool({
15
15
  name: TOOL.organizationGet,
16
16
  title: "Organization overview",
17
- description: 'A cross-application health digest for the WHOLE org, rendered as a panel: what\'s mid-rollout right now, and which scopes\' last deployment failed or rolled back. Use it for org-wide questions that name no app — "is anything broken?", "what\'s deploying?", "any failed rollouts?". Scans up to the first ~30 applications (truncation is reported, never silent). Does NOT show one app\'s full detail (use application_get), nor logs/metrics; to find the latest release or build ACROSS apps, gather headlessly with entity_list and compute it — don\'t call this. Read-only.',
17
+ description: 'A cross-application health digest for the WHOLE org, rendered as a panel: what\'s mid-rollout right now, and which scopes\' last deployment failed or rolled back. Use it ONLY for org-wide HEALTH questions that name no app — "is anything broken?", "what\'s deploying?", "any failed rollouts?". Scans up to the first ~30 applications (truncation is reported, never silent). This is HEALTH only — do NOT call it to understand org STRUCTURE or to gather context for CREATING an app (where apps live, which accounts/namespaces exist, where to place a new one): navigate that headlessly with entity_list (account → namespace → application), which renders no panel. Also does NOT show one app\'s detail (use application_get) or logs/metrics, and for the latest release/build ACROSS apps use entity_list and compute it. Read-only.',
18
18
  annotations: { readOnlyHint: true, openWorldHint: true },
19
19
  widget: "overview",
20
20
  errorKey: "overview.errorLabel",
@@ -61,7 +61,13 @@ export const paramsTool = defineTool({
61
61
  resolvedScope = { id: picked.scope.id, name: picked.scope.name };
62
62
  readNrn = picked.scope.nrn;
63
63
  }
64
- const parameters = await listParameters(context.np, readNrn, { offset: args.offset });
64
+ // A scope read interpolates so the platform resolves the EFFECTIVE value per parameter (scope
65
+ // value › most-specific dimension match › app default) — app-level/dimension values inherit
66
+ // down. Without it a scope NRN returns only values pinned at that exact scope (usually none).
67
+ const parameters = await listParameters(context.np, readNrn, {
68
+ offset: args.offset,
69
+ interpolate: Boolean(resolvedScope),
70
+ });
65
71
  if (parameters === undefined) {
66
72
  return reply(translate("params.unavailable") + linkLine(translate("params.viewInDashboard"), dashboard), { available: false, ...appRef });
67
73
  }
@@ -5,6 +5,22 @@ 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
7
  import { appArg, pickScope, requireApp } from "./shared.js";
8
+ /** Honest upsert summary: "created N" / "updated N" / a mixed count — so a retry reads as "0 new". */
9
+ function paramSummary(result, app, names) {
10
+ if (result.updated === 0) {
11
+ return plural(result.created, "setParams.created.one", "setParams.created.many", { app, names });
12
+ }
13
+ if (result.created === 0) {
14
+ return plural(result.updated, "setParams.updated.one", "setParams.updated.many", { app, names });
15
+ }
16
+ return translate("setParams.mixed", {
17
+ created: result.created,
18
+ updated: result.updated,
19
+ total: result.created + result.updated,
20
+ app,
21
+ names,
22
+ });
23
+ }
8
24
  export const setParamsTool = defineTool({
9
25
  name: TOOL.applicationParameterCreate,
10
26
  title: "Set parameters",
@@ -72,24 +88,7 @@ export const setParamsTool = defineTool({
72
88
  const result = await setParameters(context.np, app.nrn, inputs);
73
89
  const names = args.params.map((param) => `\`${param.name}\``).join(", ");
74
90
  const total = result.created + result.updated;
75
- // Honest about upsert: say how many were new vs changed so a retry reads as "0 new".
76
- const summary = result.updated === 0
77
- ? plural(result.created, "setParams.created.one", "setParams.created.many", {
78
- app: app.name,
79
- names,
80
- })
81
- : result.created === 0
82
- ? plural(result.updated, "setParams.updated.one", "setParams.updated.many", {
83
- app: app.name,
84
- names,
85
- })
86
- : translate("setParams.mixed", {
87
- created: result.created,
88
- updated: result.updated,
89
- total,
90
- app: app.name,
91
- names,
92
- });
91
+ const summary = paramSummary(result, app.name, names);
93
92
  // Mirror the params widget's shape so the SAME panel renders the resulting set (with its
94
93
  // scope picker + add affordance) after the write; the markdown stays the plain confirmation.
95
94
  const parameters = await listParameters(context.np, app.nrn).catch(() => undefined);
@@ -124,6 +124,15 @@ export function resolveChoice(options, hint, settings = {}) {
124
124
  */
125
125
  export function matchScopes(scopes, wanted) {
126
126
  const query = wanted.trim().toLowerCase();
127
+ // "#<id>" — an exact, unambiguous scope id. This is the form widgets send from their scope picker
128
+ // (the option value is the numeric id) and a documented `scope` arg form. Matched FIRST so a numeric
129
+ // id never falls through to name/substring matching — which can't see `id` and would return nothing,
130
+ // failing the call before it ever reads parameters (the widget scope-resolve bug).
131
+ const byId = /^#(\d+)$/.exec(query);
132
+ if (byId) {
133
+ const id = Number(byId[1]);
134
+ return scopes.filter((scope) => scope.id === id);
135
+ }
127
136
  const keyValue = /^([a-z0-9_-]+)\s*=\s*(.+)$/.exec(query);
128
137
  if (keyValue?.[1] && keyValue[2] !== undefined) {
129
138
  const [, dimensionKey, dimensionValue] = keyValue;
@@ -25,9 +25,9 @@ async function scopeViews(context, applicationId) {
25
25
  });
26
26
  }
27
27
  /**
28
- * Build an application's full status view (the np-panel overview payload). Exported so write
28
+ * Build an application's full status view (the app overview payload). Exported so write
29
29
  * tools that should hand off into the same live panel — e.g. create-scope, after provisioning a
30
- * new deploy target — can return this exact shape and let np-panel render the app + Deploy.
30
+ * new deploy target — can return this exact shape and let the status panel render the app + Deploy.
31
31
  */
32
32
  export async function buildAppStatus(context, app) {
33
33
  const [views, builds, releases, orgSlug] = await Promise.all([
@@ -41,9 +41,9 @@ export async function buildAppStatus(context, app) {
41
41
  export const statusTool = defineTool({
42
42
  name: TOOL.applicationGet,
43
43
  title: "Application status",
44
- description: 'THE place to start for ONE application\'s live status — renders a status PANEL: each scope with what\'s live on it (release + traffic), the latest build, the latest release, and the single obvious next action. Use it for "what\'s the status of my app", "what\'s deployed where", "is it healthy" — a STATUS question. For a DIRECT action ("deploy", "send traffic", "show logs/metrics") reach for that action\'s own tool instead of starting here; only read status first when you genuinely need to see state before acting. Call with NO arguments inside a git repo to use the linked app; pass `app` (name or "#id") for a different one; pass `deployment:<id>` to watch one rollout\'s live detail. Does NOT return logs, metrics, parameters, or a build\'s assets — use application_log_list / application_metric_list / application_parameter_list / application_build_list for those. Do NOT loop this to WAIT for a build or deployment to finish: it repaints the whole panel every call (stacking duplicate panels) — poll headlessly with entity_get (entity:"build" or "deployment", id:<id>) instead, and show this panel once at the end.',
44
+ description: 'THE place to start for ONE application\'s live status — renders a status PANEL: each scope with what\'s live on it (release + traffic), the latest build, the latest release, and the single obvious next action. Use it for "what\'s the status of my app", "what\'s deployed where", "is it healthy" — a STATUS question. For a DIRECT action ("deploy", "send traffic", "show logs/metrics") reach for that action\'s own tool instead of starting here; only read status first when you genuinely need to see state before acting. To LOOK UP a field for your OWN work — the repository_url to clone/edit, the app id, where it lives, its nrn — read it HEADLESSLY with entity_get (entity:"application", id:<id>); do NOT render this status panel just to grab a value. Call with NO arguments inside a git repo to use the linked app; pass `app` (name or "#id") for a different one; pass `deployment:<id>` to watch one rollout\'s live detail. Does NOT return logs, metrics, parameters, or a build\'s assets — use application_log_list / application_metric_list / application_parameter_list / application_build_list for those. Do NOT loop this to WAIT for a build or deployment to finish: it repaints the whole panel every call (stacking duplicate panels) — poll headlessly with entity_get (entity:"build" or "deployment", id:<id>) instead, and show this panel once at the end.',
45
45
  annotations: { readOnlyHint: true, openWorldHint: true },
46
- widget: "np-panel",
46
+ widget: "status",
47
47
  errorKey: "status.errorLabel",
48
48
  inputSchema: {
49
49
  app: appArg,
@@ -6,12 +6,43 @@ import { renderRollout } from "../render.js";
6
6
  import { defineTool, fail, reply } from "../tool.js";
7
7
  import { TOOL } from "../tool-names.js";
8
8
  import { appArg, delays, requireApp, sleep } from "./shared.js";
9
+ /**
10
+ * Find which deployment to steer. An explicit `deployment_id` wins; otherwise resolve the app and
11
+ * fan out across its scopes for the single non-terminal rollout. Mirrors requireApp's shape:
12
+ * returns { deploymentId, scope } or an `out` reply — a `fail` when nothing is in flight, or the
13
+ * disambiguation table when several rollouts are active at once (pass deployment_id to pick one).
14
+ */
15
+ async function resolveActiveRollout(context, args) {
16
+ if (args.deployment_id)
17
+ return { deploymentId: args.deployment_id };
18
+ const resolved = await requireApp(context, args);
19
+ if ("out" in resolved)
20
+ return { out: resolved.out };
21
+ const scopes = await listScopes(context.np, resolved.app.id);
22
+ const activeRollouts = [];
23
+ for (const candidate of scopes) {
24
+ const deployments = await listScopeDeployments(context.np, candidate.id, 3);
25
+ const active = deployments.find((deployment) => !isDeploymentTerminal(deployment.status));
26
+ if (active)
27
+ activeRollouts.push({ deploymentId: active.id, scope: candidate, status: active.status });
28
+ }
29
+ if (activeRollouts.length === 0) {
30
+ return { out: fail(translate("traffic.noActive", { app: resolved.app.name })) };
31
+ }
32
+ if (activeRollouts.length > 1) {
33
+ return {
34
+ out: reply(`${translate("traffic.several")}\n\n${table([translate("header.deployment"), translate("header.scope"), translate("header.status")], activeRollouts.map((rollout) => [`#${rollout.deploymentId}`, rollout.scope.name, rollout.status]))}`),
35
+ };
36
+ }
37
+ const only = activeRollouts[0]; // length === 1 checked above
38
+ return { deploymentId: only.deploymentId, scope: only.scope };
39
+ }
9
40
  export const trafficTool = defineTool({
10
41
  name: TOOL.applicationDeploymentUpdate,
11
42
  title: "Update traffic",
12
- description: 'Drive an IN-FLIGHT rollout — the step AFTER application_deployment_create. Call it DIRECTLY with `percent` (snaps to 0,1,5,10,25,50,75,90,95,99,100) to move traffic, action:"finalize" to retire the old version, or action:"rollback" to return traffic to the previous version (the safety hatch). You do NOT need to read status or look up a deployment id first — it finds the app\'s single active rollout automatically (pass `deployment_id` only when several are in flight at once). Use it for "send 25% traffic", "promote to 100%", "finalize", "roll back". Renders the live rollout panel. Does NOT create a deployment — use application_deployment_create first; this only steers one that already exists.',
43
+ description: 'Drive an IN-FLIGHT rollout — the step AFTER application_deployment_create. Call it DIRECTLY with `percent` (snaps to 0,1,5,10,25,50,75,90,95,99,100) to move traffic, action:"finalize" to retire the old version, or action:"rollback" to return traffic to the previous version (the safety hatch). You do NOT need to read status or look up a deployment id first — it finds the app\'s single active rollout automatically (pass `deployment_id` only when several are in flight at once). Use it for "send 25% traffic", "promote to 100%", "finalize", "roll back". Renders the live rollout panel. Call it ONLY for a single move the user explicitly named — in a ui client the rollout panel application_deployment_create renders already has a traffic slider, auto-advance (ramps to 100%) and finalize/rollback that drive this for the user; do NOT call this yourself to step 25 → 50 → 100 → finalize (each call stacks another rollout panel). Does NOT create a deployment — use application_deployment_create first; this only steers one that already exists.',
13
44
  annotations: { destructiveHint: true, openWorldHint: true },
14
- widget: "np-panel",
45
+ widget: "status",
15
46
  errorKey: "traffic.errorLabel",
16
47
  inputSchema: {
17
48
  app: appArg,
@@ -23,30 +54,11 @@ export const trafficTool = defineTool({
23
54
  if (args.percent === undefined && !args.action) {
24
55
  return fail(translate("traffic.sayWhat"));
25
56
  }
26
- let deploymentId = args.deployment_id;
27
- let scope;
28
- if (!deploymentId) {
29
- const resolved = await requireApp(context, args);
30
- if ("out" in resolved)
31
- return resolved.out;
32
- const scopes = await listScopes(context.np, resolved.app.id);
33
- const activeRollouts = [];
34
- for (const candidate of scopes) {
35
- const deployments = await listScopeDeployments(context.np, candidate.id, 3);
36
- const active = deployments.find((deployment) => !isDeploymentTerminal(deployment.status));
37
- if (active)
38
- activeRollouts.push({ deploymentId: active.id, scope: candidate, status: active.status });
39
- }
40
- if (activeRollouts.length === 0) {
41
- return fail(translate("traffic.noActive", { app: resolved.app.name }));
42
- }
43
- if (activeRollouts.length > 1) {
44
- return reply(`${translate("traffic.several")}\n\n${table([translate("header.deployment"), translate("header.scope"), translate("header.status")], activeRollouts.map((rollout) => [`#${rollout.deploymentId}`, rollout.scope.name, rollout.status]))}`);
45
- }
46
- const only = activeRollouts[0]; // length === 1 checked above
47
- deploymentId = only.deploymentId;
48
- scope = only.scope;
49
- }
57
+ const rollout = await resolveActiveRollout(context, args);
58
+ if ("out" in rollout)
59
+ return rollout.out;
60
+ const deploymentId = rollout.deploymentId;
61
+ let scope = rollout.scope;
50
62
  if (args.action === "finalize")
51
63
  await deploymentAction(context.np, deploymentId, "finalize");
52
64
  else if (args.action === "rollback")
package/dist/ui.js CHANGED
@@ -13,7 +13,7 @@ import { EXTENSION_ID, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY, registerAppRes
13
13
  export { EXTENSION_ID, RESOURCE_MIME_TYPE };
14
14
  /** Every widget this server can serve, by file name -> human title. */
15
15
  export const WIDGETS = {
16
- "np-panel": "Application panel",
16
+ status: "Application panel",
17
17
  "create-app": "Create application",
18
18
  params: "Parameters",
19
19
  logs: "Logs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/mcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",
@@ -47,6 +47,7 @@
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/ext-apps": "^1.7.4",
49
49
  "@modelcontextprotocol/sdk": "^1.0.0",
50
+ "pino": "^10.3.1",
50
51
  "zod": "^3.23.8"
51
52
  },
52
53
  "devDependencies": {