@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.
- package/README.md +4 -0
- package/dist/cli.js +570 -14
- 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 =
|
|
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
|
|
3595
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
33
|
+
"@belvador/api": "0.2.0",
|
|
34
34
|
"@modelcontextprotocol/server": "2.4.0",
|
|
35
35
|
"zod": "4.6.7"
|
|
36
36
|
},
|