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/dist/client.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Thin HTTP client for the GuardCMD Cloud API (data plane).
3
+ *
4
+ * Talks to `API_BASE_URL` using the caller's `GUARDCMD_API_KEY`.
5
+ * Endpoints (see platform/CONTRACT.md):
6
+ * - POST /v1/evaluate -> decision
7
+ * - GET /v1/usage -> plan/used/remaining
8
+ *
9
+ * Repository-scan control plane (account-scoped, same API key):
10
+ * - GET /v1/projects -> list projects
11
+ * - POST /v1/projects -> create project
12
+ * - POST /v1/projects/:id/scan-url -> scan a PUBLIC GitHub repo by URL
13
+ * - GET /v1/scans/:id -> scan + counts
14
+ * - GET /v1/scans/:id/surfaces -> surfaces for a scan
15
+ * - GET /v1/projects/:id/surfaces -> surfaces for latest completed scan
16
+ * - GET /v1/surfaces/:id -> a single surface
17
+ * - GET /v1/surfaces/:id/recommendations -> recommendations for a surface
18
+ * - POST /v1/recommendations/:id/autofix -> generate a PR-ready patch for a recommendation
19
+ * - POST /v1/recommendations/:id/pull-request -> open a REAL GitHub PR for a recommendation
20
+ *
21
+ * Policy + decision control plane (account-scoped, same API key):
22
+ * - GET /v1/policies?projectId= -> list policies
23
+ * - GET /v1/policies/:id -> policy + version history
24
+ * - PATCH /v1/policies/:id -> update config (optimistic concurrency)
25
+ * - POST /v1/policies/:id/promote -> promote to a target mode (shadow/live/...)
26
+ * - POST /v1/policies/:id/rollback -> roll back to a prior version
27
+ * - POST /v1/policies -> create a policy
28
+ * - GET /v1/decisions?... -> list recent decisions (paginated)
29
+ * - GET /v1/decisions/:id -> a single decision (signals/reasons/policy/feedback)
30
+ * - POST /v1/decisions/:id/feedback -> label a decision legitimate/abusive
31
+ * - GET /v1/metrics/summary?... -> aggregate metrics for a window
32
+ *
33
+ * All API errors are normalized to `ApiError` carrying `{ error, code, status }`
34
+ * so callers can surface them as MCP tool errors without crashing.
35
+ */
36
+ export class ApiError extends Error {
37
+ code;
38
+ status;
39
+ constructor(message, code, status) {
40
+ super(message);
41
+ this.name = "ApiError";
42
+ this.code = code;
43
+ this.status = status;
44
+ }
45
+ }
46
+ export class GuardCMDClient {
47
+ baseUrl;
48
+ apiKey;
49
+ fetchImpl;
50
+ timeoutMs;
51
+ constructor(opts) {
52
+ if (!opts.baseUrl)
53
+ throw new Error("API_BASE_URL is required");
54
+ if (!opts.apiKey)
55
+ throw new Error("GUARDCMD_API_KEY is required");
56
+ // Normalize: strip trailing slash so we can safely append paths.
57
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
58
+ this.apiKey = opts.apiKey;
59
+ this.fetchImpl = opts.fetchImpl ?? fetch;
60
+ this.timeoutMs = opts.timeoutMs ?? 15000;
61
+ }
62
+ async evaluate(input) {
63
+ return this.request("POST", "/v1/evaluate", input);
64
+ }
65
+ /** POST /v1/guard/prompt: screen a prompt headed for an LLM. */
66
+ async screenPrompt(input) {
67
+ return this.request("POST", "/v1/guard/prompt", input);
68
+ }
69
+ /** POST /v1/guard/tool-call: evidence-based authorization for an agent tool call. */
70
+ async authorizeToolCall(input) {
71
+ return this.request("POST", "/v1/guard/tool-call", {
72
+ ...input,
73
+ args: input.args ?? null,
74
+ });
75
+ }
76
+ async usage() {
77
+ return this.request("GET", "/v1/usage");
78
+ }
79
+ // ---- Repository-scan control plane ----
80
+ /** GET /v1/projects — the account's projects. */
81
+ async listProjects() {
82
+ const res = await this.request("GET", "/v1/projects");
83
+ return res.projects ?? [];
84
+ }
85
+ /** POST /v1/projects — create a project. */
86
+ async createProject(name) {
87
+ return this.request("POST", "/v1/projects", { name });
88
+ }
89
+ /**
90
+ * POST /v1/projects/:id/scan-url — scan a PUBLIC GitHub repository by URL.
91
+ *
92
+ * This REPLACES the old `createScan(projectId, path)`, which posted a
93
+ * server-filesystem `path` to `POST /v1/projects/:id/scans`. That endpoint took its scan
94
+ * root straight from the request body, which made it an arbitrary-file-read primitive for
95
+ * anyone holding a key (scan a host directory, then read whole file contents back out
96
+ * through the autofix endpoint). The API now answers it with
97
+ * 501 `local_path_scans_disabled` unless a development-only switch is set, so a client
98
+ * method for it would only ever produce an error.
99
+ *
100
+ * The server clones the repo itself into a disposable sandbox — the caller never names a
101
+ * path. Synchronous MVP: the response is already a terminal Scan (`completed` or
102
+ * `failed`). A malformed URL is 400 `invalid_github_url`; a private/missing repo is
103
+ * 422 `repo_unavailable`. Both arrive as {@link ApiError} with `code` intact.
104
+ */
105
+ async scanUrl(projectId, url) {
106
+ return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/scan-url`, { url });
107
+ }
108
+ /** GET /v1/scans/:id — a scan plus surface/recommendation counts. */
109
+ async getScan(scanId) {
110
+ return this.request("GET", `/v1/scans/${encodeURIComponent(scanId)}`);
111
+ }
112
+ /** GET /v1/projects/:id/surfaces — surfaces from the latest completed scan. */
113
+ async listProjectSurfaces(projectId) {
114
+ const res = await this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/surfaces`);
115
+ return res.surfaces ?? [];
116
+ }
117
+ /** GET /v1/scans/:id/surfaces — surfaces discovered by a specific scan. */
118
+ async listScanSurfaces(scanId) {
119
+ const res = await this.request("GET", `/v1/scans/${encodeURIComponent(scanId)}/surfaces`);
120
+ return res.surfaces ?? [];
121
+ }
122
+ /** GET /v1/surfaces/:id — a single surface. */
123
+ async getSurface(surfaceId) {
124
+ return this.request("GET", `/v1/surfaces/${encodeURIComponent(surfaceId)}`);
125
+ }
126
+ /** GET /v1/surfaces/:id/recommendations — hardening recommendations. */
127
+ async listSurfaceRecommendations(surfaceId) {
128
+ const res = await this.request("GET", `/v1/surfaces/${encodeURIComponent(surfaceId)}/recommendations`);
129
+ return res.recommendations ?? [];
130
+ }
131
+ /**
132
+ * POST /v1/recommendations/:id/autofix — generate a PR-ready patch/diff for a recommendation.
133
+ * The server re-scans the recommendation's source checkout and returns a review-only patch;
134
+ * it does not write files or open a real PR.
135
+ */
136
+ async generateAutofix(recommendationId) {
137
+ const res = await this.request("POST", `/v1/recommendations/${encodeURIComponent(recommendationId)}/autofix`);
138
+ return res.autofix;
139
+ }
140
+ /**
141
+ * POST /v1/recommendations/:id/pull-request — open a REAL GitHub pull request for a
142
+ * recommendation. The server re-scans a sandboxed checkout of the project's linked repo,
143
+ * generates + VALIDATES the patch, and only then opens the PR. Returns the opened PR
144
+ * (number, url, headBranch) plus a compact autofix summary. Requires a linked GitHub repo
145
+ * (else 409) and a configured GitHub App (else 501).
146
+ */
147
+ async openPullRequest(recommendationId, ref) {
148
+ return this.request("POST", `/v1/recommendations/${encodeURIComponent(recommendationId)}/pull-request`, ref ? { ref } : {});
149
+ }
150
+ // ---- Policy control plane ----
151
+ /** GET /v1/policies?projectId= — the account's policies (optionally scoped to a project). */
152
+ async listPolicies(projectId) {
153
+ const qs = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
154
+ const res = await this.request("GET", `/v1/policies${qs}`);
155
+ return res.policies ?? [];
156
+ }
157
+ /** GET /v1/policies/:id — a policy plus its version history. */
158
+ async getPolicy(policyId) {
159
+ return this.request("GET", `/v1/policies/${encodeURIComponent(policyId)}`);
160
+ }
161
+ /**
162
+ * PATCH /v1/policies/:id — update a policy's config with optimistic concurrency.
163
+ * `baseVersion` must match the current version or the API returns 409 (stale).
164
+ * Produces a new draft/shadow version; it does not enforce by itself.
165
+ */
166
+ async updatePolicy(policyId, body) {
167
+ return this.request("PATCH", `/v1/policies/${encodeURIComponent(policyId)}`, body);
168
+ }
169
+ /**
170
+ * POST /v1/policies/:id/promote — promote a policy to `targetMode`. Promoting to `live`
171
+ * REQUIRES `acknowledgeUserImpact: true` (the API returns 422 without it).
172
+ */
173
+ async promotePolicy(policyId, body) {
174
+ return this.request("POST", `/v1/policies/${encodeURIComponent(policyId)}/promote`, body);
175
+ }
176
+ /** POST /v1/policies/:id/rollback — roll a policy back to a prior version (default: previous). */
177
+ async rollbackPolicy(policyId, body = {}) {
178
+ return this.request("POST", `/v1/policies/${encodeURIComponent(policyId)}/rollback`, body);
179
+ }
180
+ /** POST /v1/policies — create a policy (from a recommendation, or from scratch). */
181
+ async createPolicy(body) {
182
+ return this.request("POST", "/v1/policies", body);
183
+ }
184
+ // ---- Decision control plane ----
185
+ /** GET /v1/decisions — recent decisions (paginated via nextCursor). */
186
+ async listDecisions(filters = {}) {
187
+ const params = new URLSearchParams();
188
+ if (filters.projectId)
189
+ params.set("projectId", filters.projectId);
190
+ if (filters.action)
191
+ params.set("action", filters.action);
192
+ if (filters.mode)
193
+ params.set("mode", filters.mode);
194
+ if (filters.enforced !== undefined)
195
+ params.set("enforced", String(filters.enforced));
196
+ if (filters.limit !== undefined)
197
+ params.set("limit", String(filters.limit));
198
+ if (filters.cursor)
199
+ params.set("cursor", filters.cursor);
200
+ const qs = params.toString();
201
+ return this.request("GET", `/v1/decisions${qs ? `?${qs}` : ""}`);
202
+ }
203
+ /** GET /v1/decisions/:id — a full decision (signals, reasons, policy, feedback). */
204
+ async getDecision(decisionId) {
205
+ return this.request("GET", `/v1/decisions/${encodeURIComponent(decisionId)}`);
206
+ }
207
+ /** POST /v1/decisions/:id/feedback — label a decision `legitimate` or `abusive`. */
208
+ async submitFeedback(decisionId, label) {
209
+ return this.request("POST", `/v1/decisions/${encodeURIComponent(decisionId)}/feedback`, { label });
210
+ }
211
+ /** GET /v1/metrics/summary — aggregate metrics for a project/window. */
212
+ async getMetrics(opts = {}) {
213
+ const params = new URLSearchParams();
214
+ if (opts.projectId)
215
+ params.set("projectId", opts.projectId);
216
+ if (opts.window)
217
+ params.set("window", opts.window);
218
+ const qs = params.toString();
219
+ return this.request("GET", `/v1/metrics/summary${qs ? `?${qs}` : ""}`);
220
+ }
221
+ async request(method, path, body) {
222
+ const url = `${this.baseUrl}${path}`;
223
+ const controller = new AbortController();
224
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
225
+ let res;
226
+ try {
227
+ res = await this.fetchImpl(url, {
228
+ method,
229
+ headers: {
230
+ // Contract accepts either scheme; send both to be robust.
231
+ Authorization: `Bearer ${this.apiKey}`,
232
+ "x-api-key": this.apiKey,
233
+ "content-type": "application/json",
234
+ accept: "application/json",
235
+ },
236
+ body: body === undefined ? undefined : JSON.stringify(body),
237
+ signal: controller.signal,
238
+ });
239
+ }
240
+ catch (err) {
241
+ clearTimeout(timer);
242
+ const msg = err instanceof Error && err.name === "AbortError"
243
+ ? `Request to ${url} timed out after ${this.timeoutMs}ms`
244
+ : `Network error calling ${url}: ${err.message}`;
245
+ throw new ApiError(msg, "network_error", 0);
246
+ }
247
+ finally {
248
+ clearTimeout(timer);
249
+ }
250
+ const text = await res.text();
251
+ let parsed = undefined;
252
+ if (text) {
253
+ try {
254
+ parsed = JSON.parse(text);
255
+ }
256
+ catch {
257
+ parsed = undefined;
258
+ }
259
+ }
260
+ if (!res.ok) {
261
+ const errBody = parsed;
262
+ const code = errBody?.code ?? `http_${res.status}`;
263
+ const message = errBody?.error ??
264
+ (text ? text.slice(0, 500) : `HTTP ${res.status} ${res.statusText}`);
265
+ throw new ApiError(message, code, res.status);
266
+ }
267
+ if (parsed === undefined) {
268
+ throw new ApiError(`Invalid JSON response from ${url}`, "invalid_response", res.status);
269
+ }
270
+ return parsed;
271
+ }
272
+ }
273
+ /** @deprecated use {@link GuardCMDClient} (the product was formerly AbuseGuard). */
274
+ export const AbuseGuardClient = GuardCMDClient;
275
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,35 @@
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 { GuardCMDClient, type GuardCMDClientOptions } from "./client.js";
14
+ export interface CreateServerOptions {
15
+ /** Public API origin, e.g. https://api.guardcmd.com (env: API_BASE_URL). */
16
+ baseUrl?: string;
17
+ /** Caller's API key (env: GUARDCMD_API_KEY). */
18
+ apiKey?: string;
19
+ /** Pre-built client (used by tests). Overrides baseUrl/apiKey if provided. */
20
+ client?: GuardCMDClient;
21
+ /** Passthrough for the underlying client (custom fetch, timeout). */
22
+ clientOptions?: Partial<Pick<GuardCMDClientOptions, "fetchImpl" | "timeoutMs">>;
23
+ }
24
+ /** Public GuardCMD API origin used when no base URL is configured. */
25
+ export declare const DEFAULT_API_BASE_URL = "https://api.guardcmd.com";
26
+ /** Resolve config from options, falling back to env vars with the exact contract names. */
27
+ export declare function resolveConfig(opts?: CreateServerOptions): {
28
+ baseUrl: string;
29
+ apiKey: string;
30
+ };
31
+ /**
32
+ * Create a fully-configured GuardCMD MCP server (tools registered).
33
+ * Throws if neither options nor env provide baseUrl + apiKey (and no client given).
34
+ */
35
+ export declare function createServer(opts?: CreateServerOptions): McpServer;