guardcmd-mcp 0.1.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/client.d.ts +426 -0
- package/dist/client.js +275 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.js +1129 -0
- package/dist/stdio.d.ts +13 -0
- package/dist/stdio.js +30 -0
- package/package.json +55 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MCP server factory for GuardCMD Cloud.
|
|
3
|
+
*
|
|
4
|
+
* Builds an `McpServer` and registers the two tools defined by the MCP contract
|
|
5
|
+
* (platform/CONTRACT.md):
|
|
6
|
+
* - `check_abuse` — mirrors POST /v1/evaluate, returns the decision.
|
|
7
|
+
* - `get_usage` — mirrors GET /v1/usage, returns plan/used/remaining.
|
|
8
|
+
*
|
|
9
|
+
* Both the stdio and HTTP entrypoints call `createServer()` so behavior is identical
|
|
10
|
+
* across transports.
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { GuardCMDClient, ApiError, } from "./client.js";
|
|
15
|
+
/** Public GuardCMD API origin used when no base URL is configured. */
|
|
16
|
+
export const DEFAULT_API_BASE_URL = "https://api.guardcmd.com";
|
|
17
|
+
/** Resolve config from options, falling back to env vars with the exact contract names. */
|
|
18
|
+
export function resolveConfig(opts = {}) {
|
|
19
|
+
// API_BASE_URL is the service contract name; GUARDCMD_BASE_URL matches the SDK. Default to the
|
|
20
|
+
// public API so a customer running `npx guardcmd-mcp` only has to supply their key.
|
|
21
|
+
const baseUrl = opts.baseUrl ?? (process.env.API_BASE_URL || process.env.GUARDCMD_BASE_URL || DEFAULT_API_BASE_URL);
|
|
22
|
+
// GUARDCMD_API_KEY first; ABUSEGUARD_API_KEY is the deprecated pre-rename name, kept as a
|
|
23
|
+
// fallback so existing configurations keep working.
|
|
24
|
+
const apiKey = opts.apiKey ?? (process.env.GUARDCMD_API_KEY || process.env.ABUSEGUARD_API_KEY || "");
|
|
25
|
+
return { baseUrl, apiKey };
|
|
26
|
+
}
|
|
27
|
+
// ---- Tool input schemas (raw zod shapes so the SDK emits JSON Schema) ----
|
|
28
|
+
const checkAbuseShape = {
|
|
29
|
+
action: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.describe("The action being evaluated, e.g. 'signup', 'login', 'post_comment', 'checkout'. Required."),
|
|
33
|
+
actorId: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Stable identifier for the acting user/account, if known."),
|
|
37
|
+
ip: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Client IP address. If omitted the API fills it from the request."),
|
|
41
|
+
email: z.string().optional().describe("Email address associated with the action."),
|
|
42
|
+
fingerprint: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Device/browser fingerprint hash, if available."),
|
|
46
|
+
userAgent: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Client User-Agent string. If omitted the API may fill it in."),
|
|
50
|
+
content: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("Free-text content to run through AI content moderation (e.g. a comment)."),
|
|
54
|
+
meta: z
|
|
55
|
+
.record(z.string(), z.unknown())
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Arbitrary additional key/value context for the evaluation."),
|
|
58
|
+
timestamp: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("ISO-8601 timestamp of the event (defaults to now)."),
|
|
62
|
+
};
|
|
63
|
+
const getUsageShape = {};
|
|
64
|
+
// ---- Repository-scan control-plane tool schemas ----
|
|
65
|
+
const listProjectsShape = {};
|
|
66
|
+
/**
|
|
67
|
+
* Input for `scan_repository` (formerly `create_scan`).
|
|
68
|
+
*
|
|
69
|
+
* The old shape took `path`: an absolute path on the API HOST, passed straight through to
|
|
70
|
+
* `POST /v1/projects/:id/scans`. That endpoint is now 501 `local_path_scans_disabled` by
|
|
71
|
+
* default, because a caller-named scan root was an arbitrary-file-read primitive — scan a
|
|
72
|
+
* host directory, then pull whole file contents back out through the autofix endpoint. The
|
|
73
|
+
* tool was renamed along with the shape so an agent reading the tool list sees a new
|
|
74
|
+
* contract rather than silently sending the old arguments into a 501.
|
|
75
|
+
*
|
|
76
|
+
* `repoUrl` is the supported replacement: the SERVER clones the repo into a disposable
|
|
77
|
+
* sandbox it owns, so the caller never names a filesystem location. Public GitHub repos
|
|
78
|
+
* only; a private repo needs the GitHub App (`POST /v1/projects/:id/scan-github`), which
|
|
79
|
+
* is not exposed as a tool yet.
|
|
80
|
+
*/
|
|
81
|
+
const scanRepositoryShape = {
|
|
82
|
+
projectId: z
|
|
83
|
+
.string()
|
|
84
|
+
.min(1)
|
|
85
|
+
.describe("ID of the project to scan (create/list projects out of band)."),
|
|
86
|
+
repoUrl: z
|
|
87
|
+
.string()
|
|
88
|
+
.min(1)
|
|
89
|
+
.describe("HTTPS URL of a PUBLIC GitHub repository to scan, e.g. " +
|
|
90
|
+
"'https://github.com/owner/repo'. The server clones it itself into a disposable " +
|
|
91
|
+
"sandbox — local/server filesystem paths are not accepted."),
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Input for `create_scan` — the DEPRECATED compatibility alias for `scan_repository`.
|
|
95
|
+
*
|
|
96
|
+
* `repoUrl` is optional (not required) here on purpose, unlike on `scan_repository`: making it
|
|
97
|
+
* required would let zod reject a legacy `{ projectId, path }` call at the schema boundary
|
|
98
|
+
* before the handler ever runs, which would surface as a terse, generic "invalid arguments"
|
|
99
|
+
* protocol error. Validating in the handler instead lets us return a precise, actionable tool
|
|
100
|
+
* error naming the security reason, the replacement argument, and the canonical tool — see
|
|
101
|
+
* `runScanRepository` / the `create_scan` registration below for why that's the goal.
|
|
102
|
+
*
|
|
103
|
+
* `path` is declared (as optional, and always optional) ONLY so a legacy call can be detected
|
|
104
|
+
* and rejected with that actionable message. It is never read for any other purpose, never
|
|
105
|
+
* forwarded to the API client, and must never become load-bearing — see the WHY comment on the
|
|
106
|
+
* `create_scan` registration below before "restoring" anything that reads it.
|
|
107
|
+
*/
|
|
108
|
+
const createScanShape = {
|
|
109
|
+
projectId: z
|
|
110
|
+
.string()
|
|
111
|
+
.min(1)
|
|
112
|
+
.describe("ID of the project to scan (create/list projects out of band)."),
|
|
113
|
+
repoUrl: z
|
|
114
|
+
.string()
|
|
115
|
+
.min(1)
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("HTTPS URL of a PUBLIC GitHub repository to scan, e.g. 'https://github.com/owner/repo' " +
|
|
118
|
+
"— identical to `scan_repository`'s `repoUrl`. The server clones it itself into a " +
|
|
119
|
+
"disposable sandbox; local/server filesystem paths are not accepted."),
|
|
120
|
+
path: z
|
|
121
|
+
.string()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("REMOVED. The legacy server-filesystem scan root. No longer supported for any value — " +
|
|
124
|
+
"sending it returns an error explaining why and telling you to send `repoUrl` instead. " +
|
|
125
|
+
"Never accepted, never acted on."),
|
|
126
|
+
};
|
|
127
|
+
const getScanShape = {
|
|
128
|
+
scanId: z.string().min(1).describe("ID of the scan to fetch."),
|
|
129
|
+
};
|
|
130
|
+
const listAbuseSurfacesShape = {
|
|
131
|
+
projectId: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Project ID — returns surfaces from its latest completed scan."),
|
|
135
|
+
scanId: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Scan ID — returns surfaces from this specific scan. Takes precedence over projectId."),
|
|
139
|
+
unprotectedOnly: z
|
|
140
|
+
.boolean()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("If true, only return surfaces with no existing protection (no `existing_protection` evidence)."),
|
|
143
|
+
priority: z
|
|
144
|
+
.array(z.string())
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Filter to these priority buckets, e.g. ['critical','high']."),
|
|
147
|
+
};
|
|
148
|
+
const listRecommendationsShape = {
|
|
149
|
+
surfaceId: z.string().min(1).describe("ID of the surface to get recommendations for."),
|
|
150
|
+
};
|
|
151
|
+
const createProtectionPrShape = {
|
|
152
|
+
recommendationId: z
|
|
153
|
+
.string()
|
|
154
|
+
.min(1)
|
|
155
|
+
.describe("ID of the recommendation to generate a protection patch for."),
|
|
156
|
+
openPr: z
|
|
157
|
+
.boolean()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("If true, OPEN A REAL GitHub pull request (requires the project to have a linked GitHub " +
|
|
160
|
+
"repo + a configured GitHub App). If false/omitted, only generate the diff for review."),
|
|
161
|
+
ref: z
|
|
162
|
+
.string()
|
|
163
|
+
.optional()
|
|
164
|
+
.describe("Base branch/ref for the PR (defaults to the repo's default branch). Only used when openPr=true."),
|
|
165
|
+
};
|
|
166
|
+
// ---- Policy + decision control-plane tool schemas ----
|
|
167
|
+
const listPoliciesShape = {
|
|
168
|
+
projectId: z
|
|
169
|
+
.string()
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("Scope to a single project's policies. Omit to list all policies on the account."),
|
|
172
|
+
};
|
|
173
|
+
const getPolicyShape = {
|
|
174
|
+
policyId: z.string().min(1).describe("ID of the policy to fetch (returns config + version history)."),
|
|
175
|
+
};
|
|
176
|
+
const setRateLimitShape = {
|
|
177
|
+
policyId: z.string().min(1).describe("ID of the policy to update."),
|
|
178
|
+
baseVersion: z
|
|
179
|
+
.number()
|
|
180
|
+
.int()
|
|
181
|
+
.describe("The policy version you read/based this change on (optimistic concurrency). " +
|
|
182
|
+
"If the policy has advanced past this, the API returns 409 and you must re-read."),
|
|
183
|
+
limits: z
|
|
184
|
+
.array(z.object({
|
|
185
|
+
dimension: z
|
|
186
|
+
.string()
|
|
187
|
+
.min(1)
|
|
188
|
+
.describe("What the limit is keyed on, e.g. 'ip', 'actorId', 'email'."),
|
|
189
|
+
limit: z.number().int().positive().describe("Max allowed events in the window."),
|
|
190
|
+
windowSeconds: z.number().int().positive().describe("Window length in seconds."),
|
|
191
|
+
}))
|
|
192
|
+
.min(1)
|
|
193
|
+
.describe("The velocity/rate limits to set on the policy."),
|
|
194
|
+
note: z.string().optional().describe("Optional human note recorded on the new version."),
|
|
195
|
+
};
|
|
196
|
+
const promotePolicyShape = {
|
|
197
|
+
policyId: z.string().min(1).describe("ID of the policy to promote."),
|
|
198
|
+
targetMode: z
|
|
199
|
+
.string()
|
|
200
|
+
.min(1)
|
|
201
|
+
.describe("Mode to promote to, e.g. 'shadow', 'live'. Promoting to 'live' ENFORCES on real users."),
|
|
202
|
+
acknowledgeUserImpact: z
|
|
203
|
+
.boolean()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe("REQUIRED to be true when targetMode is 'live' — confirms you understand this enforces " +
|
|
206
|
+
"on real users. The API returns 422 if omitted for a live promotion. Not defaulted."),
|
|
207
|
+
environment: z
|
|
208
|
+
.string()
|
|
209
|
+
.optional()
|
|
210
|
+
.describe("Optional environment label to promote in (e.g. 'production')."),
|
|
211
|
+
};
|
|
212
|
+
const rollbackPolicyShape = {
|
|
213
|
+
policyId: z.string().min(1).describe("ID of the policy to roll back."),
|
|
214
|
+
toVersion: z
|
|
215
|
+
.number()
|
|
216
|
+
.int()
|
|
217
|
+
.optional()
|
|
218
|
+
.describe("Version to roll back to. Omit to roll back to the immediately previous version."),
|
|
219
|
+
};
|
|
220
|
+
const listDecisionsShape = {
|
|
221
|
+
projectId: z.string().optional().describe("Filter to a single project."),
|
|
222
|
+
action: z.string().optional().describe("Filter to a single action, e.g. 'signup'."),
|
|
223
|
+
mode: z.string().optional().describe("Filter by policy mode, e.g. 'shadow' or 'live'."),
|
|
224
|
+
enforced: z
|
|
225
|
+
.boolean()
|
|
226
|
+
.optional()
|
|
227
|
+
.describe("If set, only decisions where enforcement was (true) / was not (false) applied."),
|
|
228
|
+
limit: z.number().int().positive().optional().describe("Max rows to return (page size)."),
|
|
229
|
+
cursor: z.string().optional().describe("Opaque pagination cursor from a previous nextCursor."),
|
|
230
|
+
};
|
|
231
|
+
const explainDecisionShape = {
|
|
232
|
+
decisionId: z.string().min(1).describe("ID of the decision to explain in full."),
|
|
233
|
+
};
|
|
234
|
+
const submitFeedbackShape = {
|
|
235
|
+
decisionId: z.string().min(1).describe("ID of the decision to label."),
|
|
236
|
+
label: z
|
|
237
|
+
.enum(["legitimate", "abusive"])
|
|
238
|
+
.describe("Ground-truth label used to tune detection: 'legitimate' or 'abusive'."),
|
|
239
|
+
};
|
|
240
|
+
const getMetricsShape = {
|
|
241
|
+
projectId: z.string().optional().describe("Scope metrics to a single project."),
|
|
242
|
+
window: z
|
|
243
|
+
.string()
|
|
244
|
+
.optional()
|
|
245
|
+
.describe("Time window for the aggregate, e.g. '24h', '7d', '30d'."),
|
|
246
|
+
};
|
|
247
|
+
const guardScopeShape = {
|
|
248
|
+
actorId: z.string().max(256).optional().describe("Stable identifier for the acting user, if known."),
|
|
249
|
+
projectId: z.string().optional().describe("Project to attribute the decision to."),
|
|
250
|
+
environment: z.string().optional().describe("Environment name (default 'production')."),
|
|
251
|
+
};
|
|
252
|
+
const screenPromptShape = {
|
|
253
|
+
prompt: z
|
|
254
|
+
.string()
|
|
255
|
+
.min(1)
|
|
256
|
+
.max(16000)
|
|
257
|
+
.describe("The end-user prompt about to be sent to an LLM (max 16k chars). Treated as untrusted data."),
|
|
258
|
+
purpose: z
|
|
259
|
+
.string()
|
|
260
|
+
.max(1000)
|
|
261
|
+
.optional()
|
|
262
|
+
.describe("What the AI feature is for (helps judge compute/token-farming abuse)."),
|
|
263
|
+
context: z.record(z.string(), z.unknown()).optional().describe("Optional extra context object."),
|
|
264
|
+
...guardScopeShape,
|
|
265
|
+
};
|
|
266
|
+
const authorizeToolCallShape = {
|
|
267
|
+
tool: z
|
|
268
|
+
.object({
|
|
269
|
+
name: z.string().min(1).max(200).describe("Tool name, e.g. 'send_email'."),
|
|
270
|
+
mutating: z
|
|
271
|
+
.boolean()
|
|
272
|
+
.describe("True if the tool writes/sends/deletes/pays. Mutating tools FAIL CLOSED when evidence is unavailable."),
|
|
273
|
+
description: z.string().max(2000).optional().describe("What the tool does."),
|
|
274
|
+
})
|
|
275
|
+
.describe("The tool the agent wants to call."),
|
|
276
|
+
args: z.unknown().optional().describe("The arguments the agent wants to pass (any JSON)."),
|
|
277
|
+
userIntent: z.string().max(4000).optional().describe("What the human user actually asked for."),
|
|
278
|
+
untrustedContext: z
|
|
279
|
+
.string()
|
|
280
|
+
.max(32000)
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("Untrusted content the agent read (web page, email, tool output) that may carry injected instructions."),
|
|
283
|
+
...guardScopeShape,
|
|
284
|
+
};
|
|
285
|
+
function summarizePromptScreen(r) {
|
|
286
|
+
return [
|
|
287
|
+
`Prompt screen: ${r.decision.toUpperCase()} (score ${r.score})`,
|
|
288
|
+
`Reasons: ${r.reasons.length ? r.reasons.join(", ") : "none"}`,
|
|
289
|
+
r.degraded ? "DEGRADED: AI judgment unavailable; heuristic result." : "",
|
|
290
|
+
]
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.join("\n");
|
|
293
|
+
}
|
|
294
|
+
function summarizeToolCall(r) {
|
|
295
|
+
return [
|
|
296
|
+
`Tool call: ${r.decision.toUpperCase()}`,
|
|
297
|
+
`Reasons: ${r.reasons.length ? r.reasons.join(", ") : "none"}`,
|
|
298
|
+
r.degraded ? "DEGRADED: AI evidence unavailable (mutating tools fail closed)." : "",
|
|
299
|
+
]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join("\n");
|
|
302
|
+
}
|
|
303
|
+
/** Max diff characters to inline in the text summary before truncating (structured content is full). */
|
|
304
|
+
const DIFF_TEXT_LIMIT = 6000;
|
|
305
|
+
/** Human-readable one-liner summary of a decision. */
|
|
306
|
+
function summarizeDecision(d) {
|
|
307
|
+
const lines = [
|
|
308
|
+
`Decision: ${d.action.toUpperCase()} (score ${d.score}, flagged=${d.flagged}, enforced=${d.enforced})`,
|
|
309
|
+
`Request ID: ${d.requestId}`,
|
|
310
|
+
];
|
|
311
|
+
if (d.reasons?.length) {
|
|
312
|
+
lines.push(`Reasons: ${d.reasons.join("; ")}`);
|
|
313
|
+
}
|
|
314
|
+
if (d.signals?.length) {
|
|
315
|
+
const sig = d.signals
|
|
316
|
+
.map((s) => `${s.signal}=${s.score}${s.reasons?.length ? ` (${s.reasons.join(", ")})` : ""}`)
|
|
317
|
+
.join(" | ");
|
|
318
|
+
lines.push(`Signals: ${sig}`);
|
|
319
|
+
}
|
|
320
|
+
return lines.join("\n");
|
|
321
|
+
}
|
|
322
|
+
/** Human-readable summary of usage. */
|
|
323
|
+
function summarizeUsage(u) {
|
|
324
|
+
const limit = u.limit == null ? "unlimited (metered)" : String(u.limit);
|
|
325
|
+
const remaining = u.remaining == null ? "n/a (metered)" : String(u.remaining);
|
|
326
|
+
const period = u.periodStart && u.periodEnd ? ` | period ${u.periodStart} → ${u.periodEnd}` : "";
|
|
327
|
+
return `Plan: ${u.plan} | used ${u.used} of ${limit} | remaining ${remaining}${period}`;
|
|
328
|
+
}
|
|
329
|
+
/** Human-readable summary of the account's projects. */
|
|
330
|
+
function summarizeProjects(projects) {
|
|
331
|
+
if (!projects.length)
|
|
332
|
+
return "No projects yet.";
|
|
333
|
+
const lines = projects.map((p) => `- ${p.name} (id ${p.id})${p.defaultEnvironment ? ` [${p.defaultEnvironment}]` : ""}`);
|
|
334
|
+
return `${projects.length} project(s):\n${lines.join("\n")}`;
|
|
335
|
+
}
|
|
336
|
+
/** Human-readable summary of a scan. */
|
|
337
|
+
function summarizeScan(scan) {
|
|
338
|
+
const lines = [
|
|
339
|
+
`Scan ${scan.id} — status: ${scan.status}`,
|
|
340
|
+
`Project: ${scan.projectId}`,
|
|
341
|
+
];
|
|
342
|
+
if (scan.scannerVersion)
|
|
343
|
+
lines.push(`Scanner: ${scan.scannerVersion}`);
|
|
344
|
+
if (scan.surfaceCount != null || scan.recommendationCount != null) {
|
|
345
|
+
lines.push(`Surfaces: ${scan.surfaceCount ?? "?"} | Recommendations: ${scan.recommendationCount ?? "?"}`);
|
|
346
|
+
}
|
|
347
|
+
if (scan.warnings?.length)
|
|
348
|
+
lines.push(`Warnings: ${scan.warnings.join("; ")}`);
|
|
349
|
+
if (scan.error)
|
|
350
|
+
lines.push(`Error: ${scan.error}`);
|
|
351
|
+
return lines.join("\n");
|
|
352
|
+
}
|
|
353
|
+
/** True if a surface already has some form of protection recorded in its evidence. */
|
|
354
|
+
function surfaceIsProtected(s) {
|
|
355
|
+
return (s.evidence ?? []).some((e) => e?.kind === "existing_protection");
|
|
356
|
+
}
|
|
357
|
+
/** Filter surfaces by priority bucket and (optionally) unprotected-only, per the PRD example. */
|
|
358
|
+
function filterSurfaces(surfaces, opts) {
|
|
359
|
+
let out = surfaces;
|
|
360
|
+
if (opts.priority?.length) {
|
|
361
|
+
const wanted = new Set(opts.priority.map((p) => p.toLowerCase()));
|
|
362
|
+
out = out.filter((s) => s.priority && wanted.has(s.priority.toLowerCase()));
|
|
363
|
+
}
|
|
364
|
+
if (opts.unprotectedOnly) {
|
|
365
|
+
out = out.filter((s) => !surfaceIsProtected(s));
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
/** Compact per-surface record for structuredContent. */
|
|
370
|
+
function compactSurface(s) {
|
|
371
|
+
return {
|
|
372
|
+
id: s.id,
|
|
373
|
+
surfaceKey: s.surfaceKey,
|
|
374
|
+
route: s.route,
|
|
375
|
+
method: s.method,
|
|
376
|
+
action: s.action,
|
|
377
|
+
surfaceType: s.surfaceType,
|
|
378
|
+
exposure: s.exposure,
|
|
379
|
+
abuseClasses: s.abuseClasses,
|
|
380
|
+
priority: s.priority,
|
|
381
|
+
priorityScore: s.priorityScore,
|
|
382
|
+
confidence: s.confidence,
|
|
383
|
+
impact: s.impact,
|
|
384
|
+
protected: surfaceIsProtected(s),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** Human-readable summary of a surface list. */
|
|
388
|
+
function summarizeSurfaces(surfaces) {
|
|
389
|
+
if (!surfaces.length)
|
|
390
|
+
return "No matching abuse surfaces.";
|
|
391
|
+
const lines = surfaces.map((s) => {
|
|
392
|
+
const loc = [s.method, s.route].filter(Boolean).join(" ") || s.action || s.surfaceKey;
|
|
393
|
+
const classes = s.abuseClasses?.length ? ` [${s.abuseClasses.join(", ")}]` : "";
|
|
394
|
+
const prot = surfaceIsProtected(s) ? "protected" : "UNPROTECTED";
|
|
395
|
+
return `- ${loc} — priority ${s.priority ?? "?"}${classes} (${prot})`;
|
|
396
|
+
});
|
|
397
|
+
return `${surfaces.length} abuse surface(s):\n${lines.join("\n")}`;
|
|
398
|
+
}
|
|
399
|
+
/** Human-readable summary of a surface's recommendations. */
|
|
400
|
+
function summarizeRecommendations(recs) {
|
|
401
|
+
if (!recs.length)
|
|
402
|
+
return "No recommendations for this surface.";
|
|
403
|
+
const lines = recs.map((r) => `- ${r.title} — priority ${r.priority ?? "?"}, status ${r.status ?? "?"}`);
|
|
404
|
+
return `${recs.length} recommendation(s):\n${lines.join("\n")}`;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Human-readable summary of a generated autofix: whether the patch is valid, how many lines it
|
|
408
|
+
* changes, any warnings, and the unified diff (truncated if very long — full diff is in
|
|
409
|
+
* structuredContent).
|
|
410
|
+
*/
|
|
411
|
+
function summarizeAutofix(a) {
|
|
412
|
+
const lines = [
|
|
413
|
+
`Autofix for recommendation ${a.recommendationId} (surface ${a.surfaceId})`,
|
|
414
|
+
`Patch: ${a.valid ? "VALID" : "INVALID / not applied"} | changed lines: ${a.estimatedChangedLines}`,
|
|
415
|
+
];
|
|
416
|
+
if (a.envAdditions?.length)
|
|
417
|
+
lines.push(`Env additions: ${a.envAdditions.join(", ")}`);
|
|
418
|
+
if (a.warnings?.length)
|
|
419
|
+
lines.push(`Warnings: ${a.warnings.join("; ")}`);
|
|
420
|
+
if (a.diff) {
|
|
421
|
+
const diff = a.diff.length > DIFF_TEXT_LIMIT
|
|
422
|
+
? `${a.diff.slice(0, DIFF_TEXT_LIMIT)}\n… (diff truncated; see structuredContent for the full patch)`
|
|
423
|
+
: a.diff;
|
|
424
|
+
lines.push("", "Unified diff:", diff);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
lines.push("", "(no diff generated)");
|
|
428
|
+
}
|
|
429
|
+
return lines.join("\n");
|
|
430
|
+
}
|
|
431
|
+
/** Human-readable summary of a REAL opened pull request. */
|
|
432
|
+
function summarizeOpenedPr(r) {
|
|
433
|
+
const lines = [
|
|
434
|
+
`Opened a real GitHub pull request: #${r.pullRequest.number}`,
|
|
435
|
+
`URL: ${r.pullRequest.url}`,
|
|
436
|
+
`Head branch: ${r.pullRequest.headBranch}`,
|
|
437
|
+
`Patch: ${r.autofix.valid ? "VALID" : "INVALID"} | changed lines: ${r.autofix.estimatedChangedLines}`,
|
|
438
|
+
];
|
|
439
|
+
if (r.autofix.diff) {
|
|
440
|
+
const diff = r.autofix.diff.length > DIFF_TEXT_LIMIT
|
|
441
|
+
? `${r.autofix.diff.slice(0, DIFF_TEXT_LIMIT)}\n… (diff truncated; see structuredContent for the full patch)`
|
|
442
|
+
: r.autofix.diff;
|
|
443
|
+
lines.push("", "Unified diff:", diff);
|
|
444
|
+
}
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
/** Human-readable summary of a list of policies. */
|
|
448
|
+
function summarizePolicies(policies) {
|
|
449
|
+
if (!policies.length)
|
|
450
|
+
return "No policies yet.";
|
|
451
|
+
const lines = policies.map((p) => {
|
|
452
|
+
const bits = [p.action, p.mode ? `mode ${p.mode}` : undefined, p.version != null ? `v${p.version}` : undefined]
|
|
453
|
+
.filter(Boolean)
|
|
454
|
+
.join(", ");
|
|
455
|
+
return `- ${p.id}${bits ? ` (${bits})` : ""}${p.projectId ? ` [${p.projectId}]` : ""}`;
|
|
456
|
+
});
|
|
457
|
+
return `${policies.length} policy/policies:\n${lines.join("\n")}`;
|
|
458
|
+
}
|
|
459
|
+
/** Human-readable summary of a single policy + its version history. */
|
|
460
|
+
function summarizePolicy(p) {
|
|
461
|
+
const lines = [
|
|
462
|
+
`Policy ${p.id}${p.action ? ` — action ${p.action}` : ""}`,
|
|
463
|
+
`Mode: ${p.mode ?? "?"} | version: ${p.version ?? "?"}${p.projectId ? ` | project ${p.projectId}` : ""}`,
|
|
464
|
+
];
|
|
465
|
+
const versions = p.versions ?? [];
|
|
466
|
+
if (versions.length) {
|
|
467
|
+
lines.push(`Version history (${versions.length}):`);
|
|
468
|
+
for (const v of versions) {
|
|
469
|
+
const parts = [` v${v.version}`, v.mode ? `mode ${v.mode}` : undefined, v.note ? `"${v.note}"` : undefined]
|
|
470
|
+
.filter(Boolean)
|
|
471
|
+
.join(" — ");
|
|
472
|
+
lines.push(parts);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return lines.join("\n");
|
|
476
|
+
}
|
|
477
|
+
/** Human-readable summary of a policy mutation (update/promote/rollback/create). */
|
|
478
|
+
function summarizePolicyChange(p, verb) {
|
|
479
|
+
return `${verb} policy ${p.id} → now mode ${p.mode ?? "?"}, version ${p.version ?? "?"}.`;
|
|
480
|
+
}
|
|
481
|
+
/** Human-readable summary of a decision list. */
|
|
482
|
+
function summarizeDecisionList(list) {
|
|
483
|
+
const rows = list.decisions ?? [];
|
|
484
|
+
if (!rows.length)
|
|
485
|
+
return "No decisions match.";
|
|
486
|
+
const lines = rows.map((d) => {
|
|
487
|
+
const bits = [
|
|
488
|
+
d.action,
|
|
489
|
+
d.outcome,
|
|
490
|
+
d.score != null ? `score ${d.score}` : undefined,
|
|
491
|
+
d.mode ? `mode ${d.mode}` : undefined,
|
|
492
|
+
`enforced=${d.enforced ?? false}`,
|
|
493
|
+
]
|
|
494
|
+
.filter(Boolean)
|
|
495
|
+
.join(", ");
|
|
496
|
+
return `- ${d.id} (${bits})`;
|
|
497
|
+
});
|
|
498
|
+
const more = list.nextCursor ? `\n(more available; nextCursor=${list.nextCursor})` : "";
|
|
499
|
+
return `${rows.length} decision(s):\n${lines.join("\n")}${more}`;
|
|
500
|
+
}
|
|
501
|
+
/** Human-readable, formatted explanation of a full decision (signals + reasons + policy). */
|
|
502
|
+
function summarizeDecisionExplanation(d) {
|
|
503
|
+
const lines = [
|
|
504
|
+
`Decision ${d.id}${d.action ? ` — action ${d.action}` : ""}`,
|
|
505
|
+
`Outcome: ${d.outcome ?? "?"} | score ${d.score ?? "?"} | mode ${d.mode ?? "?"} | enforced=${d.enforced ?? false}`,
|
|
506
|
+
];
|
|
507
|
+
if (d.reasons?.length)
|
|
508
|
+
lines.push(`Reasons: ${d.reasons.join("; ")}`);
|
|
509
|
+
if (d.signals?.length) {
|
|
510
|
+
lines.push("Signals:");
|
|
511
|
+
for (const s of d.signals) {
|
|
512
|
+
const rs = s.reasons?.length ? ` (${s.reasons.join(", ")})` : "";
|
|
513
|
+
lines.push(` - ${s.signal}: ${s.score}${rs}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (d.policy) {
|
|
517
|
+
const pid = d.policy.id;
|
|
518
|
+
lines.push(`Policy: ${pid ?? JSON.stringify(d.policy)}`);
|
|
519
|
+
}
|
|
520
|
+
if (d.feedback?.label)
|
|
521
|
+
lines.push(`Feedback: ${d.feedback.label}`);
|
|
522
|
+
return lines.join("\n");
|
|
523
|
+
}
|
|
524
|
+
/** Human-readable summary of aggregate metrics. */
|
|
525
|
+
function summarizeMetrics(m) {
|
|
526
|
+
const head = `Metrics${m.projectId ? ` for ${m.projectId}` : ""}${m.window ? ` (window ${m.window})` : ""}:`;
|
|
527
|
+
return `${head}\n${JSON.stringify(m, null, 2)}`;
|
|
528
|
+
}
|
|
529
|
+
/** Turn an error into an MCP tool error result (isError:true) without throwing. */
|
|
530
|
+
function toToolError(err) {
|
|
531
|
+
let body;
|
|
532
|
+
if (err instanceof ApiError) {
|
|
533
|
+
body = { error: err.message, code: err.code };
|
|
534
|
+
}
|
|
535
|
+
else if (err instanceof Error) {
|
|
536
|
+
body = { error: err.message, code: "mcp_error" };
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
body = { error: String(err), code: "unknown_error" };
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
isError: true,
|
|
543
|
+
content: [
|
|
544
|
+
{ type: "text", text: `GuardCMD error [${body.code}]: ${body.error}` },
|
|
545
|
+
{ type: "text", text: JSON.stringify(body) },
|
|
546
|
+
],
|
|
547
|
+
structuredContent: body,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Shared implementation for `scan_repository` and its deprecated alias `create_scan`.
|
|
552
|
+
*
|
|
553
|
+
* Both tools dispatch here for a valid `repoUrl` call so their behavior is byte-for-byte
|
|
554
|
+
* identical and can never drift — there is exactly one code path that calls
|
|
555
|
+
* `client.scanUrl()` (POST /v1/projects/:id/scan-url), and it is the only scan call either
|
|
556
|
+
* tool can make. Neither tool has any other route to the API's scan endpoints, so the retired,
|
|
557
|
+
* path-rooted `POST /v1/projects/:id/scans` is simply unreachable from here — not merely
|
|
558
|
+
* unused by convention.
|
|
559
|
+
*/
|
|
560
|
+
async function runScanRepository(client, projectId, repoUrl) {
|
|
561
|
+
try {
|
|
562
|
+
const scan = await client.scanUrl(projectId, repoUrl);
|
|
563
|
+
return {
|
|
564
|
+
content: [
|
|
565
|
+
{ type: "text", text: summarizeScan(scan) },
|
|
566
|
+
{ type: "text", text: JSON.stringify(scan, null, 2) },
|
|
567
|
+
],
|
|
568
|
+
structuredContent: scan,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
return toToolError(err);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* The actionable error returned to a legacy `create_scan` caller — one that omits `repoUrl`
|
|
577
|
+
* (most commonly because it's still sending the pre-rename `{ projectId, path }` shape).
|
|
578
|
+
*
|
|
579
|
+
* This is surfaced as a tool error result (`isError: true`), not a zod validation error, even
|
|
580
|
+
* though `create_scan`'s schema could in principle make `repoUrl` required and let the SDK
|
|
581
|
+
* reject the call for us. A schema rejection would only ever say something generic like
|
|
582
|
+
* "invalid arguments" — it can't explain WHY the field changed, WHAT to send instead, or THAT
|
|
583
|
+
* the tool itself is now deprecated. A tool error result carries free-form text, so it's the
|
|
584
|
+
* only place we can put a message that is actually useful to whoever (human or agent) is
|
|
585
|
+
* staring at the failure. Using the tool-error path is also why `repoUrl` is optional in
|
|
586
|
+
* `createScanShape` above: an optional field lets the call reach this handler instead of dying
|
|
587
|
+
* at the protocol layer first.
|
|
588
|
+
*/
|
|
589
|
+
const CREATE_SCAN_PATH_REMOVED_MESSAGE = "`create_scan` no longer scans a filesystem path. Passing `path` (or omitting `repoUrl`) " +
|
|
590
|
+
"cannot be honored: server-filesystem scan roots were removed for a security reason — any " +
|
|
591
|
+
"signed-up caller could root a scan at an arbitrary directory on the API host and read whole " +
|
|
592
|
+
"file contents back out through the autofix endpoint, so the API now rejects that endpoint " +
|
|
593
|
+
"outright (501 local_path_scans_disabled). There is no way to safely turn a filesystem path " +
|
|
594
|
+
"into a repository URL, so guessing one would be wrong — this call must fail instead. Pass " +
|
|
595
|
+
"`repoUrl` with a PUBLIC GitHub repository URL instead, e.g. " +
|
|
596
|
+
"'https://github.com/owner/repo'. Better yet, call the canonical tool `scan_repository` " +
|
|
597
|
+
"directly with the same `repoUrl` argument — `create_scan` is now only a deprecated " +
|
|
598
|
+
"compatibility alias for it.";
|
|
599
|
+
/**
|
|
600
|
+
* Create a fully-configured GuardCMD MCP server (tools registered).
|
|
601
|
+
* Throws if neither options nor env provide baseUrl + apiKey (and no client given).
|
|
602
|
+
*/
|
|
603
|
+
export function createServer(opts = {}) {
|
|
604
|
+
let client;
|
|
605
|
+
if (opts.client) {
|
|
606
|
+
client = opts.client;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
const { baseUrl, apiKey } = resolveConfig(opts);
|
|
610
|
+
client = new GuardCMDClient({
|
|
611
|
+
baseUrl,
|
|
612
|
+
apiKey,
|
|
613
|
+
fetchImpl: opts.clientOptions?.fetchImpl,
|
|
614
|
+
timeoutMs: opts.clientOptions?.timeoutMs,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const server = new McpServer({
|
|
618
|
+
name: "guardcmd-mcp",
|
|
619
|
+
version: "0.1.0",
|
|
620
|
+
}, {
|
|
621
|
+
instructions: "GuardCMD Cloud MCP server. Use `check_abuse` to evaluate whether an action " +
|
|
622
|
+
"(signup, login, comment, checkout, ...) is abusive/fraudulent — it returns a " +
|
|
623
|
+
"decision (allow/challenge/throttle/review/block) with a score, reasons, and signals. " +
|
|
624
|
+
"Use `get_usage` to see the current plan and remaining quota. " +
|
|
625
|
+
"Repository-scan control plane: `list_projects` lists your projects, `scan_repository` scans a " +
|
|
626
|
+
"PUBLIC GitHub repo by URL (the server clones it itself — filesystem paths are not " +
|
|
627
|
+
"accepted). `create_scan` is a DEPRECATED compatibility alias for `scan_repository` — " +
|
|
628
|
+
"same `repoUrl` contract, identical behavior — kept only for MCP clients still on the " +
|
|
629
|
+
"old tool name; use `scan_repository` instead, and never pass `path` (removed for " +
|
|
630
|
+
"security, it now returns an actionable error). " +
|
|
631
|
+
"`get_scan` reports scan status/counts, `list_abuse_surfaces` " +
|
|
632
|
+
"returns the abuse surfaces a scan found (filterable by priority / unprotected-only), " +
|
|
633
|
+
"`list_recommendations` returns hardening recommendations for a surface, and " +
|
|
634
|
+
"`create_protection_pr` generates a PR-ready patch/diff for a recommendation (review-only), " +
|
|
635
|
+
"or OPENS A REAL GitHub PR when called with openPr=true (needs a linked GitHub repo). " +
|
|
636
|
+
"Reads are safe; `scan_repository` and the default `create_protection_pr` are low-risk (they " +
|
|
637
|
+
"only analyze code / generate a patch); `create_protection_pr` with openPr=true opens a " +
|
|
638
|
+
"real pull request against your repo (enforcement starts in Shadow mode). " +
|
|
639
|
+
"Policy + decision control plane: `list_policies` / `get_policy` inspect anti-abuse " +
|
|
640
|
+
"policies and their version history; `set_rate_limit` sets velocity limits (creates a " +
|
|
641
|
+
"new draft/shadow version — it does NOT enforce by itself); `promote_policy` is " +
|
|
642
|
+
"HIGH-IMPACT — promoting to `live` ENFORCES on real users and REQUIRES " +
|
|
643
|
+
"`acknowledgeUserImpact: true` (omitting it on a live promotion is a 422); " +
|
|
644
|
+
"`rollback_policy` reverts to a prior version (protective). `list_decisions` browses " +
|
|
645
|
+
"recent decisions, `explain_decision` shows the full signals/reasons/policy for one, " +
|
|
646
|
+
"`submit_feedback` labels a decision legitimate/abusive, and `get_metrics` returns " +
|
|
647
|
+
"aggregate metrics for a window. " +
|
|
648
|
+
"AI guard: `screen_prompt` screens a prompt bound for an LLM (injection, exfiltration, " +
|
|
649
|
+
"token farming, harmful requests) and returns allow/review/block; `authorize_tool_call` " +
|
|
650
|
+
"returns allow/require_approval/deny for an agent tool call. Decisions come from explicit " +
|
|
651
|
+
"rules; the AI model only supplies evidence. Both are read-only checks.",
|
|
652
|
+
});
|
|
653
|
+
server.registerTool("check_abuse", {
|
|
654
|
+
title: "Check for abuse",
|
|
655
|
+
description: "Evaluate an action for abuse/fraud via GuardCMD. Returns a decision " +
|
|
656
|
+
"(allow | challenge | throttle | review | block) with score, reasons, and signals. " +
|
|
657
|
+
"Only `action` is required; provide as much context (ip, email, content, etc.) as you have.",
|
|
658
|
+
inputSchema: checkAbuseShape,
|
|
659
|
+
}, async (args) => {
|
|
660
|
+
try {
|
|
661
|
+
const decision = await client.evaluate(args);
|
|
662
|
+
return {
|
|
663
|
+
content: [
|
|
664
|
+
{ type: "text", text: summarizeDecision(decision) },
|
|
665
|
+
{ type: "text", text: JSON.stringify(decision, null, 2) },
|
|
666
|
+
],
|
|
667
|
+
structuredContent: decision,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
return toToolError(err);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
server.registerTool("screen_prompt", {
|
|
675
|
+
title: "Screen an AI prompt",
|
|
676
|
+
description: "Screen a prompt headed for an LLM for abuse via GuardCMD + TypeSafe: prompt injection / " +
|
|
677
|
+
"jailbreak, system-prompt or data exfiltration, compute/token farming, and harmful requests. " +
|
|
678
|
+
"Returns allow | review | block with a 0-100 score, reason codes, per-judgment probabilities, " +
|
|
679
|
+
"and `degraded` (true when AI judgment was unavailable and heuristics were used).",
|
|
680
|
+
inputSchema: screenPromptShape,
|
|
681
|
+
}, async (args) => {
|
|
682
|
+
try {
|
|
683
|
+
const r = await client.screenPrompt(args);
|
|
684
|
+
return {
|
|
685
|
+
content: [
|
|
686
|
+
{ type: "text", text: summarizePromptScreen(r) },
|
|
687
|
+
{ type: "text", text: JSON.stringify(r, null, 2) },
|
|
688
|
+
],
|
|
689
|
+
structuredContent: r,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
return toToolError(err);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
server.registerTool("authorize_tool_call", {
|
|
697
|
+
title: "Authorize an agent tool call",
|
|
698
|
+
description: "Get an authorization decision (allow | require_approval | deny) for an agent tool call. " +
|
|
699
|
+
"The AI model supplies EVIDENCE only (injected instructions in untrusted context, intent " +
|
|
700
|
+
"match, data exfiltration); explicit rules make the decision and can only add restriction. " +
|
|
701
|
+
"Mutating tools require approval when evidence is unavailable; read-only tools are allowed.",
|
|
702
|
+
inputSchema: authorizeToolCallShape,
|
|
703
|
+
}, async (args) => {
|
|
704
|
+
try {
|
|
705
|
+
const r = await client.authorizeToolCall(args);
|
|
706
|
+
return {
|
|
707
|
+
content: [
|
|
708
|
+
{ type: "text", text: summarizeToolCall(r) },
|
|
709
|
+
{ type: "text", text: JSON.stringify(r, null, 2) },
|
|
710
|
+
],
|
|
711
|
+
structuredContent: r,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
return toToolError(err);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
server.registerTool("get_usage", {
|
|
719
|
+
title: "Get usage",
|
|
720
|
+
description: "Get the current GuardCMD plan and usage for this API key: plan, checks used, " +
|
|
721
|
+
"limit, and remaining for the current billing period.",
|
|
722
|
+
inputSchema: getUsageShape,
|
|
723
|
+
}, async () => {
|
|
724
|
+
try {
|
|
725
|
+
const usage = await client.usage();
|
|
726
|
+
return {
|
|
727
|
+
content: [
|
|
728
|
+
{ type: "text", text: summarizeUsage(usage) },
|
|
729
|
+
{ type: "text", text: JSON.stringify(usage, null, 2) },
|
|
730
|
+
],
|
|
731
|
+
structuredContent: usage,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
return toToolError(err);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
server.registerTool("list_projects", {
|
|
739
|
+
title: "List projects",
|
|
740
|
+
description: "List the GuardCMD projects for this account (GET /v1/projects). A project is a " +
|
|
741
|
+
"container for repository scans. Read-only and safe.",
|
|
742
|
+
inputSchema: listProjectsShape,
|
|
743
|
+
}, async () => {
|
|
744
|
+
try {
|
|
745
|
+
const projects = await client.listProjects();
|
|
746
|
+
return {
|
|
747
|
+
content: [
|
|
748
|
+
{ type: "text", text: summarizeProjects(projects) },
|
|
749
|
+
{ type: "text", text: JSON.stringify({ projects }, null, 2) },
|
|
750
|
+
],
|
|
751
|
+
structuredContent: { projects },
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
return toToolError(err);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
server.registerTool("scan_repository", {
|
|
759
|
+
title: "Scan a repository",
|
|
760
|
+
description: "Scan a PUBLIC GitHub repository for abuse surfaces (POST /v1/projects/:id/scan-url). " +
|
|
761
|
+
"Pass `repoUrl`, e.g. 'https://github.com/owner/repo' — the server makes its own " +
|
|
762
|
+
"disposable clone, so server/local filesystem paths are NOT accepted (the former " +
|
|
763
|
+
"`create_scan` tool took one; that mode is disabled and now answers 501). Private " +
|
|
764
|
+
"repositories need the GitHub App flow, which is not exposed here. Low-risk write: it " +
|
|
765
|
+
"only analyzes code, it never modifies your repo. Returns the scan summary (scanId, " +
|
|
766
|
+
"status, surfaceCount, recommendationCount); a repo that cannot be cloned comes back " +
|
|
767
|
+
"as an error (invalid_github_url / repo_unavailable).",
|
|
768
|
+
inputSchema: scanRepositoryShape,
|
|
769
|
+
}, async (args) => runScanRepository(client, args.projectId, args.repoUrl));
|
|
770
|
+
/**
|
|
771
|
+
* `create_scan` — DEPRECATED compatibility alias for `scan_repository`.
|
|
772
|
+
*
|
|
773
|
+
* WHY THIS EXISTS: `create_scan` was this tool's original name, taking a server-filesystem
|
|
774
|
+
* `path`. Renaming it to `scan_repository` (with `repoUrl`) as part of the fix for the
|
|
775
|
+
* local-path-scan vulnerability means an MCP client still configured with the old name now
|
|
776
|
+
* gets an "unknown tool" error instead of a working call — an abrupt break for anyone who
|
|
777
|
+
* hadn't already migrated. This registration keeps the OLD NAME reachable without
|
|
778
|
+
* resurrecting the OLD BEHAVIOR: a valid `repoUrl` call is forwarded verbatim to
|
|
779
|
+
* `runScanRepository`, the exact same function `scan_repository` calls, so the two tools can
|
|
780
|
+
* never drift apart for the case that matters.
|
|
781
|
+
*
|
|
782
|
+
* WHY IT CAN NEVER ACCEPT A PATH: a future maintainer "restoring" `path` support here — e.g.
|
|
783
|
+
* to be extra lenient with old clients — would reopen the arbitrary-file-read primitive (root
|
|
784
|
+
* a scan at a host directory, then read whole file contents back out through the autofix
|
|
785
|
+
* endpoint) that the rename to `scan_repository` was built to close. There is no code path in
|
|
786
|
+
* this handler that reads `args.path` for anything other than deciding to reject the call, no
|
|
787
|
+
* code path that reaches `client.createScan`/`/v1/projects/:id/scans` (that endpoint has no
|
|
788
|
+
* client method at all — see client.ts), and no fallback that guesses a URL from a path.
|
|
789
|
+
* `path` exists in the schema purely so a legacy call can be told exactly what to do instead.
|
|
790
|
+
*/
|
|
791
|
+
server.registerTool("create_scan", {
|
|
792
|
+
title: "Scan a repository (deprecated alias)",
|
|
793
|
+
description: "DEPRECATED — this is a compatibility alias for `scan_repository`, kept only so MCP " +
|
|
794
|
+
"clients still configured with the old tool name don't hit an 'unknown tool' error. " +
|
|
795
|
+
"New integrations should call `scan_repository` directly. For a `repoUrl` call it " +
|
|
796
|
+
"behaves IDENTICALLY to `scan_repository`: scans a PUBLIC GitHub repository " +
|
|
797
|
+
"(POST /v1/projects/:id/scan-url), the server making its own disposable clone. The old " +
|
|
798
|
+
"`path` argument (a server-filesystem scan root) is NOT supported and never will be — " +
|
|
799
|
+
"it was a critical vulnerability (arbitrary file read via the autofix endpoint) — " +
|
|
800
|
+
"sending it (or omitting `repoUrl`) returns a tool error explaining what changed and " +
|
|
801
|
+
"telling you to send `repoUrl` instead.",
|
|
802
|
+
inputSchema: createScanShape,
|
|
803
|
+
}, async (args) => {
|
|
804
|
+
if (args.repoUrl) {
|
|
805
|
+
return runScanRepository(client, args.projectId, args.repoUrl);
|
|
806
|
+
}
|
|
807
|
+
// No repoUrl: either a legacy `{ projectId, path }` call, or repoUrl was simply omitted.
|
|
808
|
+
// Either way there is nothing safe to do but explain the change — see the constant's own
|
|
809
|
+
// doc comment for why this is a tool error result rather than a schema rejection.
|
|
810
|
+
return toToolError(new ApiError(CREATE_SCAN_PATH_REMOVED_MESSAGE, "local_path_scans_disabled", 400));
|
|
811
|
+
});
|
|
812
|
+
server.registerTool("get_scan", {
|
|
813
|
+
title: "Get scan",
|
|
814
|
+
description: "Get a scan's status and counts (GET /v1/scans/:id): status, scannerVersion, stats, " +
|
|
815
|
+
"warnings, surfaceCount, recommendationCount. Read-only and safe.",
|
|
816
|
+
inputSchema: getScanShape,
|
|
817
|
+
}, async (args) => {
|
|
818
|
+
try {
|
|
819
|
+
const scan = await client.getScan(args.scanId);
|
|
820
|
+
return {
|
|
821
|
+
content: [
|
|
822
|
+
{ type: "text", text: summarizeScan(scan) },
|
|
823
|
+
{ type: "text", text: JSON.stringify(scan, null, 2) },
|
|
824
|
+
],
|
|
825
|
+
structuredContent: scan,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
829
|
+
return toToolError(err);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
server.registerTool("list_abuse_surfaces", {
|
|
833
|
+
title: "List abuse surfaces",
|
|
834
|
+
description: "List the abuse surfaces a scan discovered (endpoints/actions that can be abused). " +
|
|
835
|
+
"Provide `scanId` for a specific scan, or `projectId` to use its latest completed scan. " +
|
|
836
|
+
"Filter with `priority` (e.g. ['critical','high']) and `unprotectedOnly` (surfaces with " +
|
|
837
|
+
"no existing protection). Read-only and safe.",
|
|
838
|
+
inputSchema: listAbuseSurfacesShape,
|
|
839
|
+
}, async (args) => {
|
|
840
|
+
try {
|
|
841
|
+
if (!args.scanId && !args.projectId) {
|
|
842
|
+
return toToolError(new ApiError("Provide either `scanId` or `projectId`.", "invalid_arguments", 400));
|
|
843
|
+
}
|
|
844
|
+
const all = args.scanId
|
|
845
|
+
? await client.listScanSurfaces(args.scanId)
|
|
846
|
+
: await client.listProjectSurfaces(args.projectId);
|
|
847
|
+
const surfaces = filterSurfaces(all, {
|
|
848
|
+
unprotectedOnly: args.unprotectedOnly,
|
|
849
|
+
priority: args.priority,
|
|
850
|
+
});
|
|
851
|
+
const compact = surfaces.map(compactSurface);
|
|
852
|
+
return {
|
|
853
|
+
content: [
|
|
854
|
+
{ type: "text", text: summarizeSurfaces(surfaces) },
|
|
855
|
+
{ type: "text", text: JSON.stringify({ surfaces: compact }, null, 2) },
|
|
856
|
+
],
|
|
857
|
+
structuredContent: {
|
|
858
|
+
surfaces: compact,
|
|
859
|
+
total: all.length,
|
|
860
|
+
returned: surfaces.length,
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
return toToolError(err);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
server.registerTool("list_recommendations", {
|
|
869
|
+
title: "List recommendations",
|
|
870
|
+
description: "List hardening recommendations for an abuse surface (GET /v1/surfaces/:id/recommendations): " +
|
|
871
|
+
"title, summary, suggestedPolicy, controls, priority, status. Read-only and safe.",
|
|
872
|
+
inputSchema: listRecommendationsShape,
|
|
873
|
+
}, async (args) => {
|
|
874
|
+
try {
|
|
875
|
+
const recommendations = await client.listSurfaceRecommendations(args.surfaceId);
|
|
876
|
+
return {
|
|
877
|
+
content: [
|
|
878
|
+
{ type: "text", text: summarizeRecommendations(recommendations) },
|
|
879
|
+
{ type: "text", text: JSON.stringify({ recommendations }, null, 2) },
|
|
880
|
+
],
|
|
881
|
+
structuredContent: { recommendations },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
return toToolError(err);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
server.registerTool("create_protection_pr", {
|
|
889
|
+
title: "Create protection PR",
|
|
890
|
+
description: "Wire GuardCMD protection into the handler for a recommendation. Two modes:\n" +
|
|
891
|
+
"• Default (openPr omitted/false): GENERATE the patch/diff for review only " +
|
|
892
|
+
"(POST /v1/recommendations/:id/autofix) — a unified diff plus the `.env` keys the " +
|
|
893
|
+
"integration needs; nothing is written to your repo.\n" +
|
|
894
|
+
"• openPr=true: OPEN A REAL GitHub pull request (POST /v1/recommendations/:id/pull-request). " +
|
|
895
|
+
"The server re-scans a sandboxed clone of the project's linked GitHub repo, validates the " +
|
|
896
|
+
"patch, and opens a PR (enforcement starts in Shadow mode). Requires a linked GitHub repo " +
|
|
897
|
+
"and a configured GitHub App; returns the PR number, url, and head branch.",
|
|
898
|
+
inputSchema: createProtectionPrShape,
|
|
899
|
+
}, async (args) => {
|
|
900
|
+
try {
|
|
901
|
+
if (args.openPr) {
|
|
902
|
+
const opened = await client.openPullRequest(args.recommendationId, args.ref);
|
|
903
|
+
return {
|
|
904
|
+
content: [
|
|
905
|
+
{ type: "text", text: summarizeOpenedPr(opened) },
|
|
906
|
+
{ type: "text", text: JSON.stringify(opened, null, 2) },
|
|
907
|
+
],
|
|
908
|
+
structuredContent: opened,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
const autofix = await client.generateAutofix(args.recommendationId);
|
|
912
|
+
return {
|
|
913
|
+
content: [
|
|
914
|
+
{ type: "text", text: summarizeAutofix(autofix) },
|
|
915
|
+
{ type: "text", text: JSON.stringify(autofix, null, 2) },
|
|
916
|
+
],
|
|
917
|
+
structuredContent: autofix,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
return toToolError(err);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
// ---- Policy control plane ----
|
|
925
|
+
server.registerTool("list_policies", {
|
|
926
|
+
title: "List policies",
|
|
927
|
+
description: "List the anti-abuse policies for this account (GET /v1/policies), optionally scoped to " +
|
|
928
|
+
"a project. Returns id, action, mode, and current version for each. Read-only and safe.",
|
|
929
|
+
inputSchema: listPoliciesShape,
|
|
930
|
+
}, async (args) => {
|
|
931
|
+
try {
|
|
932
|
+
const policies = await client.listPolicies(args.projectId);
|
|
933
|
+
return {
|
|
934
|
+
content: [
|
|
935
|
+
{ type: "text", text: summarizePolicies(policies) },
|
|
936
|
+
{ type: "text", text: JSON.stringify({ policies }, null, 2) },
|
|
937
|
+
],
|
|
938
|
+
structuredContent: { policies },
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
return toToolError(err);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
server.registerTool("get_policy", {
|
|
946
|
+
title: "Get policy",
|
|
947
|
+
description: "Get a single policy plus its full version history (GET /v1/policies/:id): config, " +
|
|
948
|
+
"current mode/version, and each prior version. Read-only and safe.",
|
|
949
|
+
inputSchema: getPolicyShape,
|
|
950
|
+
}, async (args) => {
|
|
951
|
+
try {
|
|
952
|
+
const policy = await client.getPolicy(args.policyId);
|
|
953
|
+
return {
|
|
954
|
+
content: [
|
|
955
|
+
{ type: "text", text: summarizePolicy(policy) },
|
|
956
|
+
{ type: "text", text: JSON.stringify(policy, null, 2) },
|
|
957
|
+
],
|
|
958
|
+
structuredContent: policy,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
return toToolError(err);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
server.registerTool("set_rate_limit", {
|
|
966
|
+
title: "Set rate limit",
|
|
967
|
+
description: "Set the velocity/rate limits on a policy (PATCH /v1/policies/:id). Pass `baseVersion` " +
|
|
968
|
+
"(the version you read) for optimistic concurrency — a stale value returns 409. " +
|
|
969
|
+
"Write, but LOW-RISK: this creates a new draft/shadow policy version with the limits in " +
|
|
970
|
+
"its config; it does NOT enforce on real users by itself. Use `promote_policy` to go live.",
|
|
971
|
+
inputSchema: setRateLimitShape,
|
|
972
|
+
}, async (args) => {
|
|
973
|
+
try {
|
|
974
|
+
const config = { velocityLimits: args.limits };
|
|
975
|
+
const policy = await client.updatePolicy(args.policyId, {
|
|
976
|
+
baseVersion: args.baseVersion,
|
|
977
|
+
config,
|
|
978
|
+
note: args.note,
|
|
979
|
+
});
|
|
980
|
+
return {
|
|
981
|
+
content: [
|
|
982
|
+
{ type: "text", text: summarizePolicyChange(policy, "Updated rate limits on") },
|
|
983
|
+
{ type: "text", text: JSON.stringify(policy, null, 2) },
|
|
984
|
+
],
|
|
985
|
+
structuredContent: policy,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
catch (err) {
|
|
989
|
+
return toToolError(err);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
server.registerTool("promote_policy", {
|
|
993
|
+
title: "Promote policy",
|
|
994
|
+
description: "HIGH-IMPACT / DESTRUCTIVE. Promote a policy to a target mode (POST /v1/policies/:id/promote). " +
|
|
995
|
+
"Promoting to `live` ENFORCES the policy on REAL USER traffic and REQUIRES " +
|
|
996
|
+
"`acknowledgeUserImpact: true` — this tool passes that flag through verbatim and NEVER " +
|
|
997
|
+
"defaults or infers it. If you omit it for a live promotion the API returns 422, which is " +
|
|
998
|
+
"surfaced as a tool error. Promoting to `shadow` observes without enforcing.",
|
|
999
|
+
inputSchema: promotePolicyShape,
|
|
1000
|
+
}, async (args) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const body = {
|
|
1003
|
+
targetMode: args.targetMode,
|
|
1004
|
+
};
|
|
1005
|
+
// Pass acknowledgeUserImpact through ONLY if the caller provided it — never default it.
|
|
1006
|
+
if (args.acknowledgeUserImpact !== undefined) {
|
|
1007
|
+
body.acknowledgeUserImpact = args.acknowledgeUserImpact;
|
|
1008
|
+
}
|
|
1009
|
+
if (args.environment !== undefined)
|
|
1010
|
+
body.environment = args.environment;
|
|
1011
|
+
const policy = await client.promotePolicy(args.policyId, body);
|
|
1012
|
+
return {
|
|
1013
|
+
content: [
|
|
1014
|
+
{ type: "text", text: summarizePolicyChange(policy, `Promoted (→ ${args.targetMode})`) },
|
|
1015
|
+
{ type: "text", text: JSON.stringify(policy, null, 2) },
|
|
1016
|
+
],
|
|
1017
|
+
structuredContent: policy,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
catch (err) {
|
|
1021
|
+
return toToolError(err);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
server.registerTool("rollback_policy", {
|
|
1025
|
+
title: "Rollback policy",
|
|
1026
|
+
description: "Roll a policy back to a prior version (POST /v1/policies/:id/rollback). HIGH-IMPACT but " +
|
|
1027
|
+
"PROTECTIVE — use it to quickly revert a bad config. Omit `toVersion` to revert to the " +
|
|
1028
|
+
"immediately previous version.",
|
|
1029
|
+
inputSchema: rollbackPolicyShape,
|
|
1030
|
+
}, async (args) => {
|
|
1031
|
+
try {
|
|
1032
|
+
const policy = await client.rollbackPolicy(args.policyId, { toVersion: args.toVersion });
|
|
1033
|
+
return {
|
|
1034
|
+
content: [
|
|
1035
|
+
{ type: "text", text: summarizePolicyChange(policy, "Rolled back") },
|
|
1036
|
+
{ type: "text", text: JSON.stringify(policy, null, 2) },
|
|
1037
|
+
],
|
|
1038
|
+
structuredContent: policy,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
catch (err) {
|
|
1042
|
+
return toToolError(err);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
// ---- Decision control plane ----
|
|
1046
|
+
server.registerTool("list_decisions", {
|
|
1047
|
+
title: "List decisions",
|
|
1048
|
+
description: "List recent abuse decisions (GET /v1/decisions), filterable by projectId, action, mode, " +
|
|
1049
|
+
"and enforced. Returns a compact list plus `nextCursor` for pagination. Read-only and safe.",
|
|
1050
|
+
inputSchema: listDecisionsShape,
|
|
1051
|
+
}, async (args) => {
|
|
1052
|
+
try {
|
|
1053
|
+
const list = await client.listDecisions(args);
|
|
1054
|
+
return {
|
|
1055
|
+
content: [
|
|
1056
|
+
{ type: "text", text: summarizeDecisionList(list) },
|
|
1057
|
+
{ type: "text", text: JSON.stringify(list, null, 2) },
|
|
1058
|
+
],
|
|
1059
|
+
structuredContent: list,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
return toToolError(err);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
server.registerTool("explain_decision", {
|
|
1067
|
+
title: "Explain decision",
|
|
1068
|
+
description: "Explain a single decision in full (GET /v1/decisions/:id): the outcome, score, all " +
|
|
1069
|
+
"contributing signals with their scores/reasons, the policy that applied, and any " +
|
|
1070
|
+
"feedback. Read-only and safe.",
|
|
1071
|
+
inputSchema: explainDecisionShape,
|
|
1072
|
+
}, async (args) => {
|
|
1073
|
+
try {
|
|
1074
|
+
const decision = await client.getDecision(args.decisionId);
|
|
1075
|
+
return {
|
|
1076
|
+
content: [
|
|
1077
|
+
{ type: "text", text: summarizeDecisionExplanation(decision) },
|
|
1078
|
+
{ type: "text", text: JSON.stringify(decision, null, 2) },
|
|
1079
|
+
],
|
|
1080
|
+
structuredContent: decision,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
return toToolError(err);
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
server.registerTool("submit_feedback", {
|
|
1088
|
+
title: "Submit feedback",
|
|
1089
|
+
description: "Label a decision `legitimate` or `abusive` (POST /v1/decisions/:id/feedback) to tune " +
|
|
1090
|
+
"detection. Write, but low-risk — it records ground truth and does not change enforcement.",
|
|
1091
|
+
inputSchema: submitFeedbackShape,
|
|
1092
|
+
}, async (args) => {
|
|
1093
|
+
try {
|
|
1094
|
+
const decision = await client.submitFeedback(args.decisionId, args.label);
|
|
1095
|
+
return {
|
|
1096
|
+
content: [
|
|
1097
|
+
{ type: "text", text: `Recorded feedback '${args.label}' on decision ${args.decisionId}.` },
|
|
1098
|
+
{ type: "text", text: JSON.stringify(decision, null, 2) },
|
|
1099
|
+
],
|
|
1100
|
+
structuredContent: decision,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
return toToolError(err);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
server.registerTool("get_metrics", {
|
|
1108
|
+
title: "Get metrics",
|
|
1109
|
+
description: "Get aggregate anti-abuse metrics for a project/window (GET /v1/metrics/summary): e.g. " +
|
|
1110
|
+
"decision counts, block/challenge rates, feedback. Read-only and safe.",
|
|
1111
|
+
inputSchema: getMetricsShape,
|
|
1112
|
+
}, async (args) => {
|
|
1113
|
+
try {
|
|
1114
|
+
const metrics = await client.getMetrics(args);
|
|
1115
|
+
return {
|
|
1116
|
+
content: [
|
|
1117
|
+
{ type: "text", text: summarizeMetrics(metrics) },
|
|
1118
|
+
{ type: "text", text: JSON.stringify(metrics, null, 2) },
|
|
1119
|
+
],
|
|
1120
|
+
structuredContent: metrics,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
catch (err) {
|
|
1124
|
+
return toToolError(err);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
return server;
|
|
1128
|
+
}
|
|
1129
|
+
//# sourceMappingURL=server.js.map
|