@belvador/mcp 0.1.0 → 0.2.0

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 (3) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +570 -14
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -22,6 +22,10 @@ straight from `edge.belvador.dev` with short-lived tickets, so that data never p
22
22
  - macOS: `security add-generic-password -s belvador.dev -a mcp -w`
23
23
  - Linux: `secret-tool store --label=Belvador service belvador.dev account mcp`
24
24
  - **Toolsets**: `--toolsets=investigate,reliability,docs` (the default); the token's scopes narrow them further.
25
+ Add `configure` to change settings (`search_operations`, `describe_operation`, `plan_change`, `apply_change`,
26
+ `get_operation`); it needs a token with write scopes. Sensitive changes — keys, members, tokens, alert
27
+ destinations, node changes — answer `approval_required` with a belvador.dev link: a person approves with
28
+ their authenticator code, and new secrets are shown only to them, never to the agent.
25
29
  - **Untrusted data**: everything that came from your apps is returned under `untrusted`, cleaned and size-limited;
26
30
  the server's instructions tell the agent not to follow text inside it. No tool opens URLs found in data.
27
31
  - **Logs**: diagnostics go to stderr and never contain the token or tool results.
package/dist/cli.js CHANGED
@@ -104,6 +104,7 @@ var API_ERROR_STATUS = {
104
104
  APPROVAL_EXPIRED: 410,
105
105
  APPROVAL_REJECTED: 403,
106
106
  APPROVAL_PARAMS_MISMATCH: 409,
107
+ APPROVAL_OUTCOME_UNKNOWN: 409,
107
108
  IDEMPOTENCY_KEY_REUSED: 422,
108
109
  IDEMPOTENCY_IN_PROGRESS: 409,
109
110
  PRECONDITION_REQUIRED: 428,
@@ -177,7 +178,7 @@ function cleanUntrustedText(raw, maxBytes = MCP_LIMITS.untrustedStringMaxBytes)
177
178
  const { text, cut } = cutUtf8(escaped, maxBytes);
178
179
  return { text, cut, suspicious };
179
180
  }
180
- var MCP_TOOLS_VERSION = 1;
181
+ var MCP_TOOLS_VERSION = 2;
181
182
  var MCP_TOOLS = [
182
183
  { name: "get_workspace", toolset: "investigate", scope: "org:read", readOnly: true, destructive: false, idempotent: true },
183
184
  { name: "list_projects", toolset: "investigate", scope: "projects:read", readOnly: true, destructive: false, idempotent: true },
@@ -211,12 +212,7 @@ var MCP_TOOLS_PENDING = {
211
212
  release_health: "P15",
212
213
  list_incidents: "P9",
213
214
  get_incident: "P9",
214
- get_coverage: "P10",
215
- search_operations: "P7",
216
- describe_operation: "P7",
217
- plan_change: "P7",
218
- apply_change: "P7",
219
- get_operation: "P7"
215
+ get_coverage: "P10"
220
216
  };
221
217
 
222
218
  // ../src/shared/mcp/docs.ts
@@ -258,7 +254,7 @@ function docsFetcher(siteUrl, fetcher = fetch, now = () => Date.now()) {
258
254
  }
259
255
 
260
256
  // ../src/shared/mcp/server.ts
261
- import { McpServer } from "@modelcontextprotocol/server";
257
+ import { CLIENT_CAPABILITIES_META_KEY, inputRequired, McpServer } from "@modelcontextprotocol/server";
262
258
 
263
259
  // ../src/shared/contracts/mcp-tools.ts
264
260
  import { z as z20 } from "zod";
@@ -2875,7 +2871,8 @@ var READ_INPUTS = {
2875
2871
  alertsList: list.extend({
2876
2872
  state: z18.enum(["open", "resolved", "all"]).default("all"),
2877
2873
  monitorId: uuid.optional()
2878
- })
2874
+ }),
2875
+ approvalsGet: z18.object({ workspaceId: uuid, approvalId: uuid })
2879
2876
  };
2880
2877
  var R = READ_INPUTS;
2881
2878
  var base = {
@@ -3055,6 +3052,16 @@ var READ_OPERATIONS = [
3055
3052
  paged: true,
3056
3053
  mcp: "investigate",
3057
3054
  summary: "Audit log, newest first (admins)."
3055
+ }),
3056
+ op({
3057
+ ...base,
3058
+ id: "approvals.get",
3059
+ path: "/v1/approvals/{approval_id}",
3060
+ scope: "org:read",
3061
+ rpcs: ["api_read_approval"],
3062
+ input: R.approvalsGet,
3063
+ mcp: "configure",
3064
+ summary: "State of an approval this token asked for (admins: any in the workspace): pending, approved, executed, rejected, expired or failed, with its result ids."
3058
3065
  })
3059
3066
  ];
3060
3067
 
@@ -3071,13 +3078,267 @@ var BY_ID = new Map(API_OPERATIONS.map((o) => [o.id, o]));
3071
3078
  function apiOperation(id) {
3072
3079
  return BY_ID.get(id);
3073
3080
  }
3081
+ function pathParams(path) {
3082
+ return [...path.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1]);
3083
+ }
3084
+
3085
+ // ../src/shared/contracts/approvals.ts
3086
+ var APPROVAL_STATES = ["pending", "approved", "executed", "rejected", "expired", "failed"];
3087
+ var APPROVAL_LIMITS = {
3088
+ ttlMinutes: 15,
3089
+ stepUpMaxAgeSeconds: 600,
3090
+ pendingPerWorkspace: 20,
3091
+ paramsMaxBytes: 16 * 1024,
3092
+ decisionNoteMax: 500,
3093
+ notifyMaxRecipients: 10
3094
+ };
3095
+ var APPROVAL_APPROVER = {
3096
+ "projects.set_checkin_auth": "admin",
3097
+ "projects.schedule_deletion": "admin",
3098
+ "project_keys.create": "member",
3099
+ "project_keys.rotate": "member",
3100
+ "project_keys.revoke": "member",
3101
+ "upload_tokens.create": "member",
3102
+ "upload_tokens.revoke": "member",
3103
+ "ingest_keys.create": "admin",
3104
+ "ingest_keys.revoke": "admin",
3105
+ "ping_tokens.rotate": "admin",
3106
+ "ping_tokens.revoke_previous": "admin",
3107
+ "ping_tokens.reset": "admin",
3108
+ "destinations.create": "admin",
3109
+ "destinations.update": "admin",
3110
+ "destinations.update_connection": "admin",
3111
+ "destinations.delete": "admin",
3112
+ "destinations.set_recipients": "admin",
3113
+ "members.change_role": "admin",
3114
+ "members.remove": "admin",
3115
+ "invites.create": "admin",
3116
+ "tokens.create_personal": "self",
3117
+ "tokens.create_service": "admin",
3118
+ "tokens.set_policy": "admin",
3119
+ "service_principals.create": "admin",
3120
+ "service_principals.update": "admin",
3121
+ "node.provision": "admin",
3122
+ "node.resize": "admin",
3123
+ "node.schedule_deletion": "owner",
3124
+ "node.set_backup_custody": "owner",
3125
+ "node.rebuild": "owner",
3126
+ "node.submit_recovery_kit": "owner"
3127
+ };
3128
+ var APPROVAL_HUMAN_FIELDS = {
3129
+ "projects.schedule_deletion": ["confirmSlug"],
3130
+ "node.schedule_deletion": ["confirmName"],
3131
+ "node.rebuild": ["confirmName"]
3132
+ };
3133
+ var APPROVAL_SETUP_OPS = ["destinations.create", "destinations.update_connection"];
3134
+ function approvalRequestSchema(operationId, input) {
3135
+ const human = APPROVAL_HUMAN_FIELDS[operationId] ?? [];
3136
+ if (!human.length)
3137
+ return input;
3138
+ return input.omit(Object.fromEntries(human.map((k) => [k, true])));
3139
+ }
3074
3140
 
3075
3141
  // ../src/shared/contracts/api-ops/wire.ts
3076
3142
  import { z as z19 } from "zod";
3077
3143
  var toSnake = (s) => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
3144
+ var toCamel = (s) => s.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
3078
3145
  var schemaCache = new WeakMap;
3146
+ function inputJsonSchema(op) {
3147
+ let js = schemaCache.get(op.input);
3148
+ if (!js) {
3149
+ js = z19.toJSONSchema(op.input, { io: "input", unrepresentable: "any" });
3150
+ schemaCache.set(op.input, js);
3151
+ }
3152
+ return js;
3153
+ }
3154
+ function variants(js) {
3155
+ return [js, ...js.anyOf ?? [], ...js.oneOf ?? [], ...js.allOf ?? []];
3156
+ }
3157
+ function propertyMap(js) {
3158
+ const out = new Map;
3159
+ if (!js)
3160
+ return out;
3161
+ for (const v of variants(js)) {
3162
+ for (const [name, schema] of Object.entries(v.properties ?? {}))
3163
+ out.set(toSnake(name), { name, schema });
3164
+ }
3165
+ return out;
3166
+ }
3167
+ function itemSchema(js) {
3168
+ if (!js)
3169
+ return;
3170
+ for (const v of variants(js)) {
3171
+ if (v.items && !Array.isArray(v.items))
3172
+ return v.items;
3173
+ }
3174
+ return;
3175
+ }
3176
+ function isPlainObject(v) {
3177
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3178
+ }
3179
+ function camelize(value, js) {
3180
+ if (Array.isArray(value))
3181
+ return value.map((v) => camelize(v, itemSchema(js)));
3182
+ if (!isPlainObject(value))
3183
+ return value;
3184
+ const props = propertyMap(js);
3185
+ if (props.size === 0)
3186
+ return value;
3187
+ const out = {};
3188
+ for (const [k, v] of Object.entries(value)) {
3189
+ const p = props.get(k);
3190
+ out[p ? p.name : k] = p ? camelize(v, p.schema) : v;
3191
+ }
3192
+ return out;
3193
+ }
3194
+ function snakeSchema(js, drop = []) {
3195
+ const out = { ...js };
3196
+ if (js.properties) {
3197
+ out.properties = Object.fromEntries(Object.entries(js.properties).filter(([k]) => !drop.includes(k)).map(([k, v]) => [toSnake(k), snakeSchema(v)]));
3198
+ if (js.required)
3199
+ out.required = js.required.filter((k) => !drop.includes(k)).map(toSnake);
3200
+ if (out.required && !out.required.length)
3201
+ delete out.required;
3202
+ }
3203
+ if (js.items && !Array.isArray(js.items))
3204
+ out.items = snakeSchema(js.items);
3205
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
3206
+ if (js[key])
3207
+ out[key] = js[key].map((v) => snakeSchema(v, drop));
3208
+ }
3209
+ if (isPlainObject(js.additionalProperties))
3210
+ out.additionalProperties = snakeSchema(js.additionalProperties);
3211
+ return out;
3212
+ }
3213
+ function scalarType(js) {
3214
+ if (!js)
3215
+ return;
3216
+ for (const v of variants(js)) {
3217
+ const t = Array.isArray(v.type) ? v.type.find((x) => x !== "null") : v.type;
3218
+ if (t)
3219
+ return t;
3220
+ }
3221
+ return;
3222
+ }
3223
+ function coerce(raw, js) {
3224
+ const t = scalarType(js);
3225
+ if ((t === "integer" || t === "number") && /^-?\d+(\.\d+)?$/.test(raw))
3226
+ return Number(raw);
3227
+ if (t === "boolean" && (raw === "true" || raw === "false"))
3228
+ return raw === "true";
3229
+ return raw;
3230
+ }
3231
+ function buildOpInput(op, req, workspaceId) {
3232
+ const js = inputJsonSchema(op);
3233
+ const top = propertyMap(js);
3234
+ const out = {};
3235
+ const nested = {};
3236
+ const bodyKey = op.bodyField ? toSnake(op.bodyField) : null;
3237
+ const body = isPlainObject(req.body) ? req.body : req.body === undefined || req.body === null ? {} : null;
3238
+ if (body === null) {
3239
+ out["__invalidBody"] = true;
3240
+ return out;
3241
+ }
3242
+ for (const [k, v] of Object.entries(body)) {
3243
+ const p = top.get(k);
3244
+ if (p && k !== bodyKey)
3245
+ out[p.name] = camelize(v, p.schema);
3246
+ else if (bodyKey)
3247
+ nested[k] = v;
3248
+ else
3249
+ out[k] = v;
3250
+ }
3251
+ if (op.bodyField)
3252
+ out[op.bodyField] = camelize(nested, top.get(bodyKey)?.schema);
3253
+ for (const [k, raw] of Object.entries(req.query)) {
3254
+ const p = top.get(k);
3255
+ if (p)
3256
+ out[p.name] = coerce(raw, p.schema);
3257
+ }
3258
+ for (const name of pathParams(op.path)) {
3259
+ const p = top.get(name);
3260
+ const raw = req.path[name];
3261
+ if (p && raw !== undefined)
3262
+ out[p.name] = coerce(raw, p.schema);
3263
+ }
3264
+ if (op.workspace)
3265
+ out["workspaceId"] = workspaceId;
3266
+ return out;
3267
+ }
3268
+ function wirePointer(op, path) {
3269
+ const segs = path.map((p) => typeof p === "string" ? toSnake(p) : String(p));
3270
+ if (op.bodyField && segs[0] === toSnake(op.bodyField))
3271
+ segs.shift();
3272
+ return `/${segs.map((s) => s.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
3273
+ }
3274
+
3275
+ // ../src/shared/contracts/api-ops/openapi.ts
3276
+ function clean(js) {
3277
+ const { $schema: _drop, ...rest } = js;
3278
+ return rest;
3279
+ }
3280
+ function propertyOf(js, camel) {
3281
+ return clean(snakeSchema(js.properties?.[camel] ?? {}));
3282
+ }
3283
+ function requestSchema(op) {
3284
+ const js = inputJsonSchema(op);
3285
+ const path = pathParams(op.path).map(toCamel);
3286
+ const drop = ["workspaceId", ...path];
3287
+ if (!op.bodyField) {
3288
+ const s = clean(snakeSchema(js, drop));
3289
+ return s.properties && Object.keys(s.properties).length === 0 && !s.anyOf && !s.oneOf ? null : s;
3290
+ }
3291
+ const outer = clean(snakeSchema(js, [...drop, op.bodyField]));
3292
+ const inner = clean(snakeSchema(js.properties?.[op.bodyField] ?? {}));
3293
+ if (!inner.properties)
3294
+ return { allOf: [outer, inner] };
3295
+ return {
3296
+ ...inner,
3297
+ properties: { ...outer.properties ?? {}, ...inner.properties },
3298
+ required: [...outer.required ?? [], ...inner.required ?? []]
3299
+ };
3300
+ }
3301
+ function operationArgsSchema(op, drop = []) {
3302
+ const js = inputJsonSchema(op);
3303
+ const path = pathParams(op.path);
3304
+ const pathProps = Object.fromEntries(path.map((n) => [n, propertyOf(js, toCamel(n))]));
3305
+ const rest = op.method === "GET" ? (() => {
3306
+ const s = clean(snakeSchema(js, ["workspaceId", ...path.map(toCamel)]));
3307
+ return { ...s, required: [] };
3308
+ })() : requestSchema(op);
3309
+ if (rest && !rest.properties) {
3310
+ return { type: "object", properties: pathProps, required: path, allOf: [rest] };
3311
+ }
3312
+ const props = { ...pathProps, ...rest?.properties ?? {} };
3313
+ for (const d of drop)
3314
+ delete props[d];
3315
+ const required = [...path, ...rest?.required ?? []].filter((k) => !drop.includes(k));
3316
+ return {
3317
+ type: "object",
3318
+ properties: props,
3319
+ ...required.length ? { required: [...new Set(required)] } : {},
3320
+ additionalProperties: false
3321
+ };
3322
+ }
3079
3323
 
3080
3324
  // ../src/shared/contracts/mcp-tools.ts
3325
+ function wireFromArgs(op, args) {
3326
+ const names = new Set(pathParams(op.path));
3327
+ const path = {};
3328
+ const query = {};
3329
+ const body = {};
3330
+ for (const [k, v] of Object.entries(args)) {
3331
+ if (v === undefined || v === null)
3332
+ continue;
3333
+ if (names.has(k))
3334
+ path[k] = String(v);
3335
+ else if (op.method === "GET")
3336
+ query[k] = typeof v === "string" ? v : JSON.stringify(v);
3337
+ else
3338
+ body[k] = v;
3339
+ }
3340
+ return { path, query, body: op.method === "GET" ? undefined : body };
3341
+ }
3081
3342
  function wireInput(operationId, opts = {}) {
3082
3343
  const op = apiOperation(operationId);
3083
3344
  if (!op)
@@ -3198,6 +3459,19 @@ var TOOL_OUTPUT_SCHEMA = z20.object({
3198
3459
  detail: z20.string().optional(),
3199
3460
  scope: z20.string().optional(),
3200
3461
  retry_after: z20.number().optional()
3462
+ }).optional(),
3463
+ operations: z20.array(z20.record(z20.string(), z20.unknown())).optional(),
3464
+ operation: z20.record(z20.string(), z20.unknown()).optional(),
3465
+ plan: z20.record(z20.string(), z20.unknown()).optional(),
3466
+ status: z20.enum(["done", "approval_required", "approval_pending", ...APPROVAL_STATES]).optional(),
3467
+ approval: z20.object({
3468
+ approval_id: z20.string(),
3469
+ approval_url: z20.string(),
3470
+ expires_at: z20.string(),
3471
+ state: z20.enum(APPROVAL_STATES),
3472
+ result_code: z20.string().nullable().optional(),
3473
+ result: z20.record(z20.string(), z20.unknown()).optional(),
3474
+ next: z20.string().optional()
3201
3475
  }).optional()
3202
3476
  });
3203
3477
  var spec = (name) => {
@@ -3252,6 +3526,79 @@ var MONITOR_KINDS2 = {
3252
3526
  uptime: { list: "uptime.list", get: "uptime.get", idField: "check_id" },
3253
3527
  slo: { list: "slos.list", get: "slos.get", idField: "slo_id" }
3254
3528
  };
3529
+ var DESTRUCTIVE_VERBS = new Set([
3530
+ "delete",
3531
+ "revoke",
3532
+ "remove",
3533
+ "schedule_deletion",
3534
+ "rotate",
3535
+ "reset",
3536
+ "revoke_previous",
3537
+ "rebuild",
3538
+ "change_role"
3539
+ ]);
3540
+ function configureOperations() {
3541
+ return API_OPERATIONS.filter((o) => o.mcp === "configure");
3542
+ }
3543
+ function configureOp(id) {
3544
+ if (typeof id !== "string")
3545
+ return null;
3546
+ const op = apiOperation(id);
3547
+ return op && op.mcp === "configure" ? op : null;
3548
+ }
3549
+ function isDestructiveOperation(op) {
3550
+ return op.method === "DELETE" || DESTRUCTIVE_VERBS.has(op.id.slice(op.id.indexOf(".") + 1));
3551
+ }
3552
+ var humanWire = (op) => (APPROVAL_HUMAN_FIELDS[op.id] ?? []).map(toSnake);
3553
+ function resourceRead(op) {
3554
+ if (op.method === "GET")
3555
+ return null;
3556
+ return API_OPERATIONS.find((o) => o.method === "GET" && o.path === op.path && o.kind !== "node") ?? null;
3557
+ }
3558
+ var PLACEHOLDER_WS = "00000000-0000-0000-0000-000000000000";
3559
+ function validateConfigure(op, params) {
3560
+ for (const f of humanWire(op))
3561
+ if (f in params)
3562
+ return { ok: false, errors: [{ pointer: `/${f}`, code: "typed_by_approver" }] };
3563
+ const input = buildOpInput(op, wireFromArgs(op, params), PLACEHOLDER_WS);
3564
+ if (input["__invalidBody"])
3565
+ return { ok: false, errors: [{ pointer: "", code: "object_expected" }] };
3566
+ const schema = op.sensitivity === "approval" ? approvalRequestSchema(op.id, op.input) : op.input;
3567
+ const r = schema.safeParse(input);
3568
+ if (r.success)
3569
+ return { ok: true };
3570
+ return {
3571
+ ok: false,
3572
+ errors: r.error.issues.slice(0, 20).map((i) => ({ pointer: wirePointer(op, i.path), code: i.code }))
3573
+ };
3574
+ }
3575
+ function describe(op, scopes) {
3576
+ return {
3577
+ operation_id: op.id,
3578
+ summary: op.summary,
3579
+ method: op.method,
3580
+ path: op.path,
3581
+ scope: op.scope,
3582
+ scope_held: scopes.includes(op.scope),
3583
+ requires_approval: op.sensitivity === "approval",
3584
+ destructive: isDestructiveOperation(op)
3585
+ };
3586
+ }
3587
+ var params = z20.record(z20.string(), z20.unknown()).default({}).describe("Operation fields in wire names (snake_case), as describe_operation's args_schema shows.");
3588
+ var operationIdArg = z20.string().min(3).max(80).describe("An operation id from search_operations, e.g. `monitors.update`.");
3589
+ function approvalBlock(t, next) {
3590
+ return {
3591
+ approval_id: t.approval_id,
3592
+ approval_url: t.approval_url,
3593
+ expires_at: t.expires_at,
3594
+ state: t.state,
3595
+ ...t.result_code !== undefined ? { result_code: t.result_code } : {},
3596
+ ...t.result !== undefined ? { result: t.result } : {},
3597
+ next
3598
+ };
3599
+ }
3600
+ var NEXT_PENDING = "A person with the right role must approve this on belvador.dev (open approval_url; it needs their authenticator code). Nothing has changed yet. Show the link to the user, then call get_operation with approval_id, or apply_change again with the same idempotency_key to get the result.";
3601
+ var NEXT_SETUP = "The approving person also types the secret (bot token, webhook URL, password) on that page; it never passes through the agent.";
3255
3602
  var MCP_TOOL_DEFS = [
3256
3603
  {
3257
3604
  ...spec("get_workspace"),
@@ -3442,6 +3789,189 @@ var MCP_TOOL_DEFS = [
3442
3789
  return wrapResult({ data: r.body, operations: ["projects.send_test_event"], source: "control_plane" });
3443
3790
  }
3444
3791
  },
3792
+ {
3793
+ ...spec("search_operations"),
3794
+ title: "Find a configuration change",
3795
+ description: "Searches what this token can change in its Belvador workspace: projects, keys and tokens, monitors, uptime checks, SLOs, alert rules and destinations, members, service accounts and the node. Returns operation ids for describe_operation and plan_change, whether a person must approve the change, and whether this token holds the scope.",
3796
+ operations: [],
3797
+ input: z20.object({
3798
+ query: z20.string().min(1).max(200).describe("Words such as `rotate dsn key`, `heartbeat monitor`, `slack alert`."),
3799
+ limit: z20.number().int().min(1).max(20).default(10)
3800
+ }),
3801
+ async run(args, rt) {
3802
+ const q = words(String(args["query"]));
3803
+ const scored = configureOperations().filter((o) => o.method !== "GET").map((o) => {
3804
+ const id = words(o.id.replace(/[._]/g, " "));
3805
+ const rest = words(`${o.summary} ${o.path}`);
3806
+ let score = 0;
3807
+ for (const w of q) {
3808
+ if (id.some((t) => t === w))
3809
+ score += 4;
3810
+ else if (id.some((t) => t.startsWith(w)))
3811
+ score += 2;
3812
+ if (rest.some((t) => t === w))
3813
+ score += 2;
3814
+ else if (rest.some((t) => t.startsWith(w)))
3815
+ score += 1;
3816
+ }
3817
+ return { o, score };
3818
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || a.o.id.localeCompare(b.o.id)).slice(0, Number(args["limit"] ?? 10));
3819
+ return { structuredContent: { operations: scored.map((x) => describe(x.o, rt.scopes)) } };
3820
+ }
3821
+ },
3822
+ {
3823
+ ...spec("describe_operation"),
3824
+ title: "Describe a configuration change",
3825
+ description: "Everything needed to call one operation: its fields (JSON Schema, wire names), the scope, whether it needs If-Match (call plan_change for the current ETag), an idempotency key, or a person's approval, and which fields the approving person types on belvador.dev instead of the agent.",
3826
+ operations: [],
3827
+ input: z20.object({ operation_id: operationIdArg }),
3828
+ async run(args, rt) {
3829
+ const op = configureOp(args["operation_id"]);
3830
+ if (!op || op.method === "GET")
3831
+ return errorResult({ ok: false, status: 404, code: "NOT_FOUND" });
3832
+ const approval = op.sensitivity === "approval";
3833
+ return {
3834
+ structuredContent: {
3835
+ operation: {
3836
+ ...describe(op, rt.scopes),
3837
+ idempotency: op.idempotency,
3838
+ if_match_required: !!op.ifMatch,
3839
+ args_schema: operationArgsSchema(op, humanWire(op)),
3840
+ ...approval ? {
3841
+ approver: APPROVAL_APPROVER[op.id] ?? "admin",
3842
+ typed_by_approver: humanWire(op),
3843
+ secrets_typed_by_approver: APPROVAL_SETUP_OPS.includes(op.id)
3844
+ } : {},
3845
+ ...op.secretOutput?.length ? { secret_output: [...op.secretOutput] } : {}
3846
+ }
3847
+ }
3848
+ };
3849
+ }
3850
+ },
3851
+ {
3852
+ ...spec("plan_change"),
3853
+ title: "Check a change before applying it",
3854
+ description: "Validates params for one operation without changing anything: field errors, missing scope, whether a person must approve, and — when the resource can be read — its current state, the fields that would change and the ETag to pass as if_match.",
3855
+ operations: [],
3856
+ input: z20.object({ operation_id: operationIdArg, params }),
3857
+ async run(args, rt) {
3858
+ const op = configureOp(args["operation_id"]);
3859
+ if (!op || op.method === "GET")
3860
+ return errorResult({ ok: false, status: 404, code: "NOT_FOUND" });
3861
+ const p = args["params"] ?? {};
3862
+ const v = validateConfigure(op, p);
3863
+ const scopeMissing = rt.scopes.includes(op.scope) ? [] : [op.scope];
3864
+ let etag = null;
3865
+ let current = undefined;
3866
+ const read = resourceRead(op);
3867
+ const ids = read ? Object.fromEntries(pathParams(read.path).map((n) => [n, p[n]])) : {};
3868
+ if (read && rt.scopes.includes(read.scope) && Object.values(ids).every((x) => x != null && x !== "")) {
3869
+ const r = await rt.call(read.id, ids);
3870
+ if (r.ok) {
3871
+ etag = r.etag ?? null;
3872
+ current = r.body;
3873
+ }
3874
+ }
3875
+ const changes = Object.entries(p).filter(([k]) => !pathParams(op.path).includes(k)).map(([field, to]) => {
3876
+ const cur = current && typeof current === "object" && !Array.isArray(current) ? current : null;
3877
+ return cur && field in cur ? { field, from: cur[field], to } : { field, to };
3878
+ });
3879
+ const plan = {
3880
+ operation_id: op.id,
3881
+ valid: v.ok,
3882
+ ...v.ok ? {} : { errors: v.errors },
3883
+ scope: op.scope,
3884
+ scope_missing: scopeMissing,
3885
+ requires_approval: op.sensitivity === "approval",
3886
+ ...op.sensitivity === "approval" ? { approver: APPROVAL_APPROVER[op.id] ?? "admin" } : {},
3887
+ destructive: isDestructiveOperation(op),
3888
+ idempotency: op.idempotency,
3889
+ if_match_required: !!op.ifMatch,
3890
+ etag
3891
+ };
3892
+ const out = wrapResult({
3893
+ data: { changes, ...current !== undefined ? { current } : {} },
3894
+ operations: read && current !== undefined ? [read.id] : [],
3895
+ source: "control_plane"
3896
+ });
3897
+ return { structuredContent: { plan, ...out.structuredContent } };
3898
+ }
3899
+ },
3900
+ {
3901
+ ...spec("apply_change"),
3902
+ title: "Apply a configuration change",
3903
+ description: "Runs one operation with params (validate first with plan_change). Always send idempotency_key: repeating a call with the same key never applies it twice. Sensitive operations answer approval_required with an approval_url a person opens on belvador.dev; nothing changes until they approve with their authenticator code.",
3904
+ operations: [],
3905
+ input: z20.object({
3906
+ operation_id: operationIdArg,
3907
+ params,
3908
+ idempotency_key: z20.string().min(8).max(255).describe("Unique per change; reuse it to retry or to fetch the result."),
3909
+ if_match: z20.string().max(200).optional().describe("ETag from plan_change, for operations that need it.")
3910
+ }),
3911
+ async run(args, rt) {
3912
+ const op = configureOp(args["operation_id"]);
3913
+ if (!op || op.method === "GET")
3914
+ return errorResult({ ok: false, status: 404, code: "NOT_FOUND" });
3915
+ const p = args["params"] ?? {};
3916
+ for (const f of humanWire(op))
3917
+ if (f in p)
3918
+ return errorResult({ ok: false, status: 400, code: "INVALID_INPUT", detail: `${f} is typed by the approving person` });
3919
+ const opts = { idempotencyKey: String(args["idempotency_key"]) };
3920
+ if (typeof args["if_match"] === "string" && args["if_match"])
3921
+ opts.ifMatch = args["if_match"];
3922
+ const r = await rt.call(op.id, p, opts);
3923
+ if (r.ok) {
3924
+ const body = r.body;
3925
+ if (op.sensitivity === "approval" && body && typeof body["approval_id"] === "string") {
3926
+ return {
3927
+ structuredContent: {
3928
+ status: "executed",
3929
+ approval: approvalBlock(body, "Approved and run once by a person; result holds the ids.")
3930
+ }
3931
+ };
3932
+ }
3933
+ const out = wrapResult({ data: r.body, operations: [op.id], source: sourceOf(op.id) });
3934
+ return { structuredContent: { status: "done", ...out.structuredContent } };
3935
+ }
3936
+ if (r.approval && r.approval.state === "pending") {
3937
+ const next = APPROVAL_SETUP_OPS.includes(op.id) ? `${NEXT_PENDING} ${NEXT_SETUP}` : NEXT_PENDING;
3938
+ return {
3939
+ structuredContent: { status: "approval_required", approval: approvalBlock(r.approval, next) },
3940
+ approval: r.approval
3941
+ };
3942
+ }
3943
+ if (r.approval) {
3944
+ return {
3945
+ isError: true,
3946
+ structuredContent: {
3947
+ ...errorResult(r).structuredContent,
3948
+ approval: approvalBlock(r.approval, "This request is closed; send a new one with a new idempotency_key if the change is still wanted.")
3949
+ }
3950
+ };
3951
+ }
3952
+ return errorResult(r);
3953
+ }
3954
+ },
3955
+ {
3956
+ ...spec("get_operation"),
3957
+ title: "Follow an approval",
3958
+ description: "State of a change waiting for a person's approval: pending, approved, executed (with the ids it produced), rejected, expired or failed (with the error code).",
3959
+ operations: ["approvals.get"],
3960
+ input: z20.object({ approval_id: z20.guid().describe("approval_id from apply_change.") }),
3961
+ async run(args, rt) {
3962
+ const r = await rt.call("approvals.get", { approval_id: args["approval_id"] });
3963
+ if (!r.ok)
3964
+ return errorResult(r);
3965
+ const b = r.body;
3966
+ const next = b.state === "pending" ? NEXT_PENDING : b.state === "executed" ? "Done: the change ran once." : b.state === "approved" ? "Approved; the change is running or waits for the person to finish the form." : "Closed without a change.";
3967
+ return {
3968
+ structuredContent: {
3969
+ status: b.state,
3970
+ approval: approvalBlock({ ...b, approval_url: b.approval_url ?? "" }, next)
3971
+ }
3972
+ };
3973
+ }
3974
+ },
3445
3975
  {
3446
3976
  ...spec("search_docs"),
3447
3977
  title: "Search Belvador docs",
@@ -3542,6 +4072,12 @@ function toolsFor(toolsets, scopes) {
3542
4072
 
3543
4073
  // ../src/shared/mcp/server.ts
3544
4074
  var MCP_SERVER_VERSION = `1.${MCP_TOOLS_VERSION}.0`;
4075
+ var APPROVAL_ELICIT_MESSAGE = "Belvador needs a person to approve this change. Open the link, review it and confirm with your authenticator code; then the agent continues.";
4076
+ function wantsApprovalLink(ctx) {
4077
+ const env = ctx?.mcpReq.envelope;
4078
+ const caps = env?.[CLIENT_CAPABILITIES_META_KEY];
4079
+ return !!caps?.elicitation?.url && !ctx?.mcpReq.inputResponses?.["approval"];
4080
+ }
3545
4081
  function buildMcpServer(rt, toolsets) {
3546
4082
  const server = new McpServer({ name: "belvador", title: "Belvador", version: MCP_SERVER_VERSION }, { instructions: MCP_SERVER_INSTRUCTIONS });
3547
4083
  for (const t of toolsFor([...toolsets], rt.scopes)) {
@@ -3557,8 +4093,15 @@ function buildMcpServer(rt, toolsets) {
3557
4093
  idempotentHint: t.idempotent,
3558
4094
  openWorldHint: false
3559
4095
  }
3560
- }, async (args) => {
4096
+ }, async (args, ctx) => {
3561
4097
  const r = await t.run(args, rt);
4098
+ if (r.approval && r.approval.state === "pending" && wantsApprovalLink(ctx)) {
4099
+ return inputRequired({
4100
+ inputRequests: {
4101
+ approval: inputRequired.elicitUrl({ message: APPROVAL_ELICIT_MESSAGE, url: r.approval.approval_url })
4102
+ }
4103
+ });
4104
+ }
3562
4105
  return {
3563
4106
  content: [{ type: "text", text: JSON.stringify(r.structuredContent) }],
3564
4107
  structuredContent: r.structuredContent,
@@ -3579,7 +4122,15 @@ function outcomeOfError(e) {
3579
4122
  code: e.code === "ABORTED" ? "UNAVAILABLE" : e.code,
3580
4123
  ...e.detail ? { detail: e.detail } : {},
3581
4124
  ...e.scope ? { scope: e.scope } : {},
3582
- ...e.retryAfter ? { retryAfter: e.retryAfter } : {}
4125
+ ...e.retryAfter ? { retryAfter: e.retryAfter } : {},
4126
+ ...e.approval ? {
4127
+ approval: {
4128
+ approval_id: e.approval.id,
4129
+ approval_url: e.approval.url,
4130
+ expires_at: e.approval.expiresAt,
4131
+ state: e.approval.state
4132
+ }
4133
+ } : {}
3583
4134
  };
3584
4135
  return { ok: false, status: 503, code: "UNAVAILABLE" };
3585
4136
  }
@@ -3591,8 +4142,13 @@ function clientRuntime(client, scopes, docs) {
3591
4142
  async call(operationId, args, opts) {
3592
4143
  try {
3593
4144
  const call = client.call.bind(client);
3594
- const r = await call(operationId, args, opts?.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {});
3595
- return { ok: true, status: r.status, body: r.data };
4145
+ const o = {};
4146
+ if (opts?.idempotencyKey)
4147
+ o.idempotencyKey = opts.idempotencyKey;
4148
+ if (opts?.ifMatch)
4149
+ o.ifMatch = opts.ifMatch;
4150
+ const r = await call(operationId, args, o);
4151
+ return { ok: true, status: r.status, body: r.data, etag: r.etag };
3596
4152
  } catch (e) {
3597
4153
  return outcomeOfError(e);
3598
4154
  }
@@ -3624,7 +4180,7 @@ function findToken(env = process.env, keychain = keychainToken) {
3624
4180
  }
3625
4181
 
3626
4182
  // packages/mcp/src/cli.ts
3627
- var PACKAGE_VERSION = "0.1.0";
4183
+ var PACKAGE_VERSION = "0.2.0";
3628
4184
  var HELP = `belvador-mcp ${PACKAGE_VERSION} — Belvador MCP server (stdio)
3629
4185
 
3630
4186
  Usage: npx -y @belvador/mcp [--toolsets=${MCP_DEFAULT_TOOLSETS.join(",")}]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@belvador/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Belvador MCP server over stdio: the same read-only tools as api.belvador.dev/mcp (issues, events, traces, logs, monitors, docs), with node data read straight from the edge.",
5
5
  "license": "MIT",
6
6
  "author": "Belvador (https://belvador.dev)",
@@ -30,7 +30,7 @@
30
30
  "node": ">=20.10"
31
31
  },
32
32
  "dependencies": {
33
- "@belvador/api": "0.1.0",
33
+ "@belvador/api": "0.2.0",
34
34
  "@modelcontextprotocol/server": "2.4.0",
35
35
  "zod": "4.6.7"
36
36
  },