@nullplatform/mcp 0.1.15 → 0.1.16

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.
@@ -206,7 +206,11 @@ function mapDeployment(raw) {
206
206
  switchedTraffic: strategyData.switchedTraffic ?? strategyData.switched_traffic,
207
207
  desiredSwitchedTraffic: strategyData.desiredSwitchedTraffic ?? strategyData.desired_switched_traffic,
208
208
  },
209
- messages: raw.messages ?? [],
209
+ messages: (raw.messages ?? []).map((entry) => ({
210
+ timestamp: entry.timestamp ?? entry.created_at,
211
+ source: entry.source ?? entry.level,
212
+ message: entry.message ?? "",
213
+ })),
210
214
  };
211
215
  }
212
216
  export async function createDeployment(np, args) {
@@ -216,7 +220,10 @@ export async function createDeployment(np, args) {
216
220
  return mapDeployment(await np.post("/deployment", body));
217
221
  }
218
222
  export async function getDeployment(np, deploymentId) {
219
- return mapDeployment(await np.get(`/deployment/${deploymentId}`));
223
+ // include_messages=true is REQUIRED for the platform to return the deployment's lifecycle log
224
+ // (`messages`) — without it the array is empty and the panel's "Deployment log" stays blank
225
+ // (core-entities routes/deployment.js maps the flag onto GET /deployment/:id). Verified against source.
226
+ return mapDeployment(await np.get(`/deployment/${deploymentId}`, { include_messages: true }));
220
227
  }
221
228
  /** Latest deployments of a scope, newest first. */
222
229
  export async function listScopeDeployments(np, scopeId, limit = 3) {
@@ -8,7 +8,7 @@ const INSTRUCTIONS = `PRECEDENCE — READ FIRST. While these nullplatform tools
8
8
 
9
9
  nullplatform is where this code gets built, released, deployed and observed — these tools replace its web dashboard for the everyday developer journey.
10
10
 
11
- The tools are repo-aware: inside a git repo, omit \`app\` and the linked application is inferred from the git remote. "This app", or a request that names nothing, means the repo's app — infer it; pass \`app\` only when the user means a different, explicitly named application. Start with \`application_get\` — it shows what's live where and suggests the next action.
11
+ The tools are repo-aware: inside a git repo, omit \`app\` and the linked application is inferred from the git remote. "This app", or a request that names nothing, means the repo's app — infer it; pass \`app\` only when the user means a different, explicitly named application. \`application_get\` is the entry point for a STATUS question ("what's deployed where", "is it healthy", "what's going on") — it shows what's live and the next action. But for a DIRECT request — "show logs", "show metrics", "deploy", "roll back", "send traffic" — call THAT tool directly with the app name (each resolves the app itself); do NOT render \`application_get\` first to "resolve the app" or "orient" — that just drops a status panel the user didn't ask for. "show <app> logs" is ONE call: \`application_log_list app:"<app>"\`.
12
12
 
13
13
  You run in the developer's own environment, so fuse the local repo with platform state. Read the git remote, branch, HEAD commit, the diff being shipped, and config files (\`.env\`, \`package.json\`, Dockerfile), and correlate them with what the platform reports: does the local HEAD match a built or released commit, does a local config value match what a scope resolves. That correlation is this integration's edge over the web dashboard, which only sees the platform half.
14
14
 
@@ -36,7 +36,7 @@ Typical flows:
36
36
  - First time on a repo: \`application_create\` → push a commit (CI builds) → \`application_deployment_create\`.
37
37
  - Trouble: \`application_get\` → \`application_log_list\` + \`application_metric_list\` (golden signals) → \`application_deployment_update action:"rollback"\` if needed.
38
38
 
39
- THE ROLLOUT PANEL OWNS THE TRAFFIC WALK. \`application_deployment_create\` renders a live rollout panel whose OWN controls — a traffic slider, auto-advance (ramps to 100%), finalize and rollbackcall \`application_deployment_update\` through the host bridge with NO new panel. So in a ui client, deploy ONCE and STOP: do NOT fire \`application_deployment_update percent:25 → 50 → 100 → finalize\` yourself to step traffic each call repaints the whole rollout panel and stacks duplicate panels down the chat (exactly what "deploy and ship" produced). The user drives the walk in the panel. Call \`application_deployment_update\` directly ONLY for a single specific move the user explicitly asked for ("go straight to 100%", "finalize now", "roll back"), or in a text client with no panel. One deployone rollout panel.
39
+ THE DEPLOYMENT IS ONE ROLLOUT PANEL everything happens in it. \`application_deployment_create\` renders ONE live rollout panel: provisioning status, a traffic slider, auto-advance (ramps to 100%), finalize, rollback, and live logs ALL in that single panel. \`application_deployment_update\` steers the SAME panel (its on-panel controls call it through the host bridge, in place). So deploy ONCE and let the user walk traffic, finalize, roll back and watch provisioning/logs IN that one panel — do NOT fire \`application_deployment_update percent:25 → 50 → 100 → finalize\` yourself to step it, because each model call STACKS ANOTHER rollout panel down the chat. Call \`application_deployment_update\` directly ONLY for a single move the user explicitly asked for ("go straight to 100%", "finalize now", "roll back") with no panel already open. One deploymentONE rollout panel; never render a second.
40
40
 
41
41
  When the user is WATCHING fresh activity — verifying a deploy, "tail the logs", "is it crashing now?" — keep logs LIVE (the logs panel defaults to live and auto-refreshes) or pass a recent window (\`start_time\` = a minute or two ago), so they see CURRENT lines streaming in, not a stale snapshot of the last 50 lines (which can be hours old on a quiet app).
42
42
 
package/dist/tool.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { currentLocale, matchLocale, translate, withLocale } from "./i18n.js";
3
+ import { log } from "./log.js";
3
4
  import { uiMeta, uiNegotiated, widgetUri } from "./ui.js";
4
5
  /** Markdown for the human + JSON for the model/widget. */
5
6
  export function reply(markdown, data) {
@@ -97,11 +98,30 @@ export function registerTools(server, tools, context) {
97
98
  annotations: tool.annotations,
98
99
  ...(tool.widget ? { _meta: uiMeta(widgetUri(tool.widget)) } : {}),
99
100
  inputSchema: { ...tool.inputSchema, language: languageArg },
100
- }, async (rawArgs) => present(await runInUserLanguage(tool, rawArgs, context), {
101
- uiActive: splitsForModel(tool) && uiNegotiated(server),
102
- }));
101
+ }, async (rawArgs) => {
102
+ const toolReply = await runInUserLanguage(tool, rawArgs, context);
103
+ // Trace EVERY tool call: the tool name, the widget it renders, and whether THIS host actually
104
+ // paints a panel — so "why did N panels open for one interaction?" is answerable from the log
105
+ // (grep the panel:true lines). Arg KEYS only, never values — a value can carry a secret.
106
+ log.debug({
107
+ toolCall: {
108
+ name: tool.name,
109
+ widget: tool.widget ?? null,
110
+ panel: Boolean(tool.widget) && uiNegotiated(server) && !toolReply.isError,
111
+ ok: !toolReply.isError,
112
+ args: argKeys(rawArgs),
113
+ },
114
+ }, "tool call");
115
+ return present(toolReply, { uiActive: splitsForModel(tool) && uiNegotiated(server) });
116
+ });
103
117
  }
104
118
  }
119
+ /** Arg KEYS only (never values — they can carry secrets) for the tool-call trace. */
120
+ function argKeys(rawArgs) {
121
+ if (!rawArgs || typeof rawArgs !== "object")
122
+ return [];
123
+ return Object.keys(rawArgs).filter((key) => key !== "language");
124
+ }
105
125
  /** Honour the LLM-declared conversation language, falling back to the ambient locale. */
106
126
  async function runInUserLanguage(tool, rawArgs, context) {
107
127
  const { language, ...args } = (rawArgs ?? {});
@@ -124,7 +124,7 @@ async function resolveDeployAsset(context, release, scope, assetHint) {
124
124
  export const deployTool = defineTool({
125
125
  name: TOOL.applicationDeploymentCreate,
126
126
  title: "Deploy",
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.',
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, the rollout panel this renders OWNS the traffic walk — its slider + auto-advance ramp traffic, with finalize and rollback right there, so do NOT follow up with application_deployment_update to step it yourself (each model call stacks another rollout panel); the user drives it in the ONE panel. Call application_deployment_update directly only for one move the user explicitly names ("go straight to 100%", "roll back") with no panel open — it steers this same rollout. Convergent under retries: re-running returns the in-flight rollout for the same scope+release rather than creating a duplicate.',
128
128
  annotations: { destructiveHint: false, openWorldHint: true },
129
129
  widget: "status",
130
130
  errorKey: "deploy.errorLabel",
@@ -13,7 +13,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
13
13
  export const deploymentsTool = defineTool({
14
14
  name: TOOL.applicationDeploymentList,
15
15
  title: "Deployments",
16
- description: 'List ONE application\'s deployments across all its scopes, rendered as a PANEL — which release landed on which scope, the rollout status, and age (a deployment group landing one release on several scopes shows as one row). Use it for "deploy history", "what was deployed when", "find the active rollout". Does NOT deploy or change traffic (use application_deployment_create / application_deployment_update) and shows no logs/metrics. To find the newest deployment ACROSS apps, gather headlessly with entity_list (entity:"deployment") and compute it. Read-only.',
16
+ description: 'List ONE application\'s deployments across all its scopes, rendered as a PANEL for the user to SEE — which release landed on which scope, the rollout status, and age (a deployment group landing one release on several scopes shows as one row). Use it ONLY when the user asks to look at the deploy history ("show the deployments", "what was deployed when"). Does NOT deploy or change traffic (use application_deployment_create / application_deployment_update) and shows no logs/metrics. CRUCIAL do NOT render this to QUERY: to FIND or IDENTIFY a deployment for your OWN next step — the previously deployed version to roll back to, the active rollout\'s id, the newest deployment across apps read it HEADLESSLY with entity_list (entity:"deployment", parent_type:"scope") or entity_get, never this panel. Rendering it to read history is wasted: the rows ride in _meta and never reach your reasoning, so you would paint a panel, get nothing, and have to re-read headlessly anyway. Render this ONLY to show the user a list they asked to see. Read-only.',
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
18
  widget: "deployments",
19
19
  errorKey: "deploymentList.errorLabel",
@@ -66,7 +66,7 @@ async function listByNrn(context, entity, nrn) {
66
66
  export const entityListTool = defineTool({
67
67
  name: TOOL.entityList,
68
68
  title: "List entities (data)",
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.',
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 any DATA lookup you reason over — whether AGGREGATING across apps ("the latest release across all my apps", "which app has a failing build", "every scope on the newest build") OR reading ONE app\'s history to identify something for your next move ("the previously deployed version to roll back to", "the release behind the current deployment", "what version ran before this one", "find the active rollout"). List the app\'s deployments/releases/builds with this (NO panel), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list/application_get WHENEVER you are gathering to reason including a single app, not just aggregates because those reads RENDER A PANEL the user didn\'t ask for; and the LIST panels (release/build/deployment) split their rows into _meta, so they would not even reach your reasoning. Those reads are ONLY for showing the user a list/status they asked to see. 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.',
70
70
  annotations: { readOnlyHint: true },
71
71
  // No widget on purpose: data-only navigation. See CLAUDE.md (the data-only read pattern).
72
72
  errorKey: "entityList.errorLabel",
@@ -12,7 +12,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
12
12
  export const releasesTool = defineTool({
13
13
  name: TOOL.applicationReleaseList,
14
14
  title: "Releases",
15
- description: "List ONE application's releases as a PANEL for the user — 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. Do NOT call this once per app to compare across apps (it renders a panel each time): for an AGGREGATE like 'the latest release across all my apps' use entity_list (entity:\"release\"), gather headlessly, and compute the answer.",
15
+ description: "List ONE application's releases as a PANEL for the user to SEE — semver, status (active/deprecated/unstable/failed), the build each pins, and age; each row carries a Deploy button. Use it ONLY when the user wants to look at what's shippable or click a release to deploy. CRUCIAL — do NOT render this to QUERY: to FIND or IDENTIFY a release for your OWN next step the previous version to roll back to, the release behind the current deployment, the latest across apps read it HEADLESSLY with entity_list (entity:\"release\") and compute the answer, never this panel. Rendering it to read history is wasted: the rows ride in _meta and never reach your reasoning, so you would paint a panel, get nothing, and re-read headlessly anyway. Render this ONLY to show the user a list they asked to see.",
16
16
  annotations: { readOnlyHint: true, openWorldHint: true },
17
17
  widget: "releases",
18
18
  errorKey: "releaseList.errorLabel",
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { dashboardLink } from "../md.js";
3
3
  import { pmap } from "../np/context.js";
4
- import { getDeployment, getScope, isDeploymentTerminal, listBuilds, listReleases, listScopeDeployments, listScopes, } from "../np/journey.js";
4
+ import { getDeployment, getRelease, getScope, isDeploymentTerminal, listBuilds, listReleases, listScopeDeployments, listScopes, } from "../np/journey.js";
5
5
  import { renderRollout, renderStatus } from "../render.js";
6
6
  import { defineTool, reply } from "../tool.js";
7
7
  import { TOOL } from "../tool-names.js";
@@ -41,7 +41,7 @@ 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. 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.',
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", "roll back", "send traffic", "show logs/metrics") reach for that action\'s own tool do NOT render this panel FIRST to "check current state" or "see what\'s live" before acting. When you need current state to DECIDE (which version is live, what the previous version is), read it HEADLESSLY with entity_get / entity_list and act on that; rendering this panel just to orient drops a status panel the user never asked for. Render it ONLY when the USER wants to SEE status. 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
46
  widget: "status",
47
47
  errorKey: "status.errorLabel",
@@ -52,13 +52,20 @@ export const statusTool = defineTool({
52
52
  async handler(args, context) {
53
53
  if (args.deployment) {
54
54
  const deployment = await getDeployment(context.np, args.deployment);
55
- const [scope, orgSlug] = await Promise.all([
55
+ // Resolve the release too, so the rollout panel keeps showing its version on every live poll
56
+ // (this read is what the panel polls every 2.5s — without the release, release_semver goes null
57
+ // and the version disappears after the first refresh).
58
+ const [scope, release, orgSlug] = await Promise.all([
56
59
  deployment.scope_id ? getScope(context.np, deployment.scope_id).catch(() => undefined) : undefined,
60
+ deployment.release_id
61
+ ? getRelease(context.np, deployment.release_id).catch(() => undefined)
62
+ : undefined,
57
63
  context.org.organizationSlug(),
58
64
  ]);
59
65
  const { md, structured } = renderRollout({
60
66
  deployment,
61
67
  scope,
68
+ release,
62
69
  dashboard: dashboardLink(orgSlug, scope?.nrn),
63
70
  });
64
71
  return reply(md, structured);
@@ -40,7 +40,7 @@ async function resolveActiveRollout(context, args) {
40
40
  export const trafficTool = defineTool({
41
41
  name: TOOL.applicationDeploymentUpdate,
42
42
  title: "Update traffic",
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.',
43
+ description: 'Drive an IN-FLIGHT rollout, rendered as the live rollout PANEL — the SAME single panel application_deployment_create opens, where provisioning status, the traffic slider, auto-advance, finalize, rollback and live logs ALL live. 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". THE ROLLOUT IS ONE PANEL: if a rollout panel is already open (you just deployed), the user walks traffic, finalizes and rolls back IN it via its own controls do NOT call this yourself to step 25 → 50 → 100 → finalize, because each model call stacks ANOTHER rollout panel. Call this directly ONLY for one move the user explicitly named with no panel open. Does NOT create a deployment — use application_deployment_create first; this only steers one that already exists.',
44
44
  annotations: { destructiveHint: true, openWorldHint: true },
45
45
  widget: "status",
46
46
  errorKey: "traffic.errorLabel",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/mcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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",