@skaleagents/swarm 0.6.17 → 0.6.19

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 CHANGED
@@ -5,12 +5,9 @@ SkaleAgents MCP server. The hosted connection is `https://skaleagents.com/mcp`.
5
5
  ## Quick start
6
6
 
7
7
  Copy `https://skaleagents.com/mcp` into your client and choose OAuth. Sign in
8
- with Google and click Allow. Leave client ID and secret fields blank when the
9
- client asks. Setup steps for Cursor, Claude Code, Claude Desktop, ChatGPT, and
10
- Codex are on the [MCP config page](https://skaleagents.com/mcp-config).
11
-
12
- The hosted URL is the way to connect. A local stdio process can still run this
13
- package; it opens the browser for OAuth when no token is set.
8
+ with Google and click Allow. Leave client ID and secret blank when the client
9
+ asks. Setup steps for Cursor, Claude Code, Claude Desktop, ChatGPT, and Codex
10
+ are on the [MCP config page](https://skaleagents.com/mcp-config).
14
11
 
15
12
  Tools: `plan_architecture_review`, `review_application_architecture`,
16
13
  `review_architecture`, and `scan_iac`. The older `scan_iac_stub` name remains
@@ -111,15 +108,9 @@ locations point to resource declarations; property paths identify the setting.
111
108
  YAML aliases must be expanded before submission. Coverage limits are included
112
109
  in every result. An empty finding list is not proof that a system is secure.
113
110
 
114
- ## Hosted connection
115
-
116
- Use `https://skaleagents.com/mcp` in Claude Desktop or ChatGPT's custom connector
117
- settings. Choose OAuth and leave client ID and secret fields blank. The client
118
- registers itself, opens Google sign-in, and asks you to click Allow.
119
- See [client setup](https://skaleagents.com/mcp-config) for Cursor, Claude Code,
120
- Claude Desktop, ChatGPT, and Codex instructions.
111
+ ## Hosted transport
121
112
 
122
- The web app hosts this endpoint using `handleMcpRequest` from
113
+ The web app hosts `https://skaleagents.com/mcp` using `handleMcpRequest` from
123
114
  `@skaleagents/swarm/http`. Audit workers import `scanIac` from
124
115
  `@skaleagents/swarm/scan`. It validates each bearer credential with the API's
125
116
  `/api/oauth/mcp-token` endpoint before running a tool. Tokens are bound to the
@@ -129,94 +120,23 @@ forwarded to the public bot directory.
129
120
  `protectedResourceMetadata` exports the discovery response for
130
121
  `/.well-known/oauth-protected-resource/mcp`.
131
122
 
132
- ## Local stdio prerequisites
133
-
134
- 1. Node.js 20 or newer. The client connects to `https://api.skaleagents.com` by default.
135
- 2. A browser that can open the SkaleAgents sign-in page.
136
-
137
- The first tool call opens browser sign-in. Approve MCP access there and return
138
- to your AI client. The package stores the OAuth refresh credential locally and
139
- refreshes access automatically. Active connections can be revoked from the
140
- [MCP config page](https://skaleagents.com/mcp-config).
141
-
142
- Local `review_architecture` calls validate the account and fetch public bot
143
- hints concurrently. The public directory request carries no credential. Every
144
- review still requires successful account validation. An anonymous directory
145
- request may already be in flight when validation rejects a connection.
146
-
147
123
  ## Local development
148
124
 
125
+ People working on this repo need Node.js 20 or newer. This is not the way to connect a client.
126
+
149
127
  ```bash
150
128
  git clone https://github.com/SkaleAgents/mcp-server.git
151
129
  cd mcp-server
152
130
  npm install
153
- cp .env.example .env
154
131
  npm run build
155
132
  npm test
156
- npm run smoke # needs API on :8082
157
- ```
158
-
159
- ## Cursor
160
-
161
- Add to `.cursor/mcp.json` (project) or Cursor Settings → MCP:
162
-
163
- ### Option A: local clone (recommended while API is local)
164
-
165
- ```json
166
- {
167
- "mcpServers": {
168
- "skaleagents": {
169
- "command": "node",
170
- "args": ["/absolute/path/to/mcp-server/dist/index.js"],
171
- "env": {
172
- "PLATFORM_API_URL": "http://localhost:8082"
173
- }
174
- }
175
- }
176
- }
177
133
  ```
178
134
 
179
- Dev without build:
180
-
181
- ```json
182
- {
183
- "mcpServers": {
184
- "skaleagents": {
185
- "command": "npx",
186
- "args": ["tsx", "/absolute/path/to/mcp-server/src/index.ts"],
187
- "env": {
188
- "PLATFORM_API_URL": "http://localhost:8082"
189
- }
190
- }
191
- }
192
- }
193
- ```
194
-
195
- ### Option B: published package (hosted API)
196
-
197
- ```json
198
- {
199
- "mcpServers": {
200
- "skaleagents": {
201
- "command": "npx",
202
- "args": ["-y", "@skaleagents/swarm@0.6.1"]
203
- }
204
- }
205
- }
206
- ```
207
-
208
- Reconnect after upgrading to refresh the tool catalog. The consultation tools,
209
- snippet review, IaC scanner, and compatibility alias should all appear.
210
-
211
- ## Claude Code
212
-
213
- Use the published package configuration above in `.mcp.json`. OAuth starts on the first tool call. No API URL or keys are needed.
214
-
215
135
  ## Environment
216
136
 
217
137
  | Variable | Required | Description |
218
138
  |----------|----------|-------------|
219
- | `SKALEAGENTS_API_TOKEN` | No | Legacy Sanctum bearer-token override. OAuth is used when empty. |
139
+ | `SKALEAGENTS_API_TOKEN` | No | Legacy override for CI only, not the way to connect. |
220
140
  | `PLATFORM_API_URL` | No | Defaults to `https://api.skaleagents.com`. Override only for local development or another API deployment. Empty values use the default. |
221
141
  | `SKALEAGENTS_OAUTH_CACHE` | No | OAuth cache path. Default `~/.config/skaleagents/oauth.json`. |
222
142
  | `SKALEAGENTS_OAUTH_ENABLED` | No | Set to `false` only to disable browser OAuth. |
@@ -224,7 +144,7 @@ Use the published package configuration above in `.mcp.json`. OAuth starts on th
224
144
 
225
145
  ## Steady-state tool latency probe
226
146
 
227
- With an existing, authorized `SKALEAGENTS_API_TOKEN` in the environment, run
147
+ This probe uses the legacy `SKALEAGENTS_API_TOKEN` CI override. It is not how a client connects. With that variable already set, run
228
148
  `npm run benchmark:tool`. The script builds this checkout, starts its stdio MCP
229
149
  server, makes five warmup calls, then times 50 `review_architecture` calls on a
230
150
  fixed synthetic application snippet. Set `BENCHMARK_WARMUP` from 0 to 20 and
@@ -3,14 +3,16 @@ import { requireApiAuth, unauthorizedContent } from "../auth.js";
3
3
  import { ScanInputError } from "../iac/parse.js";
4
4
  import { contextSchema, fileSchema, planReview, consultationInstructions, } from "./intake.js";
5
5
  import { reviewApplication } from "./review.js";
6
- async function run(remote, compute) {
6
+ import { applicationTargetSchema, withSavedReview } from "../saved-review.js";
7
+ async function run(remote, compute, save) {
7
8
  if (!remote) {
8
9
  const auth = await requireApiAuth();
9
10
  if (!auth.ok)
10
11
  return unauthorizedContent(auth);
11
12
  }
12
13
  try {
13
- const output = compute();
14
+ const computed = compute();
15
+ const output = save ? await withSavedReview(computed, save) : computed;
14
16
  return {
15
17
  content: [
16
18
  { type: "text", text: JSON.stringify(output, null, 2) },
@@ -67,8 +69,13 @@ export function registerConsultation(server, remote) {
67
69
  .max(80)
68
70
  .describe("Related source/config/test files with repository-relative paths; include package.json and tsconfig.json. At most 500,000 combined content characters"),
69
71
  context: contextSchema,
72
+ target: applicationTargetSchema,
70
73
  },
71
- }, ({ files, context }) => run(remote, () => reviewApplication(files, context)));
74
+ }, ({ files, context, target }) => run(remote, () => reviewApplication(files, context), {
75
+ tool: "review_application_architecture",
76
+ target: target ?? null,
77
+ submittedPaths: files.map((file) => file.path),
78
+ }));
72
79
  server.registerPrompt("architecture_consultation", {
73
80
  title: "Independent application architecture consultation",
74
81
  description: "Guide a multi-turn whole-application review with source evidence, follow-up questions, and design tradeoffs.",
package/dist/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
2
  import { createServer } from "./server.js";
3
3
  import { getApiUrl } from "./config.js";
4
+ import { requestAuthorization } from "./saved-review.js";
4
5
  const resource = process.env.MCP_RESOURCE_URL ?? "https://skaleagents.com/mcp";
5
6
  const metadataUrl = `${new URL(resource).origin}/.well-known/oauth-protected-resource/mcp`;
6
7
  const allowedOrigins = new Set([
@@ -105,30 +106,32 @@ export async function handleMcpRequest(request) {
105
106
  error: { code: -32700, message: "Invalid JSON" },
106
107
  });
107
108
  }
108
- const server = createServer(true);
109
- const transport = new WebStandardStreamableHTTPServerTransport({
110
- sessionIdGenerator: undefined,
111
- enableJsonResponse: true,
112
- });
113
- try {
114
- await server.connect(transport);
115
- const response = await transport.handleRequest(request, {
116
- parsedBody: body,
109
+ return requestAuthorization.run(authorization, async () => {
110
+ const server = createServer(true);
111
+ const transport = new WebStandardStreamableHTTPServerTransport({
112
+ sessionIdGenerator: undefined,
113
+ enableJsonResponse: true,
117
114
  });
118
- // Finish the JSON response before closing this request-scoped transport.
119
- const responseBody = await response.arrayBuffer();
120
- const responseHeaders = new Headers(response.headers);
121
- for (const [key, value] of Object.entries(headers))
122
- responseHeaders.set(key, value);
123
- return new Response(responseBody.byteLength ? responseBody : null, {
124
- status: response.status,
125
- headers: responseHeaders,
126
- });
127
- }
128
- catch {
129
- return json(500, { error: "mcp_request_failed" });
130
- }
131
- finally {
132
- await server.close();
133
- }
115
+ try {
116
+ await server.connect(transport);
117
+ const response = await transport.handleRequest(request, {
118
+ parsedBody: body,
119
+ });
120
+ // Finish the JSON response before closing this request-scoped transport.
121
+ const responseBody = await response.arrayBuffer();
122
+ const responseHeaders = new Headers(response.headers);
123
+ for (const [key, value] of Object.entries(headers))
124
+ responseHeaders.set(key, value);
125
+ return new Response(responseBody.byteLength ? responseBody : null, {
126
+ status: response.status,
127
+ headers: responseHeaders,
128
+ });
129
+ }
130
+ catch {
131
+ return json(500, { error: "mcp_request_failed" });
132
+ }
133
+ finally {
134
+ await server.close();
135
+ }
136
+ });
134
137
  }
@@ -0,0 +1,36 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { z } from "zod";
3
+ export declare const savedTargetSchema: z.ZodOptional<z.ZodObject<{
4
+ repository: z.ZodOptional<z.ZodString>;
5
+ path: z.ZodOptional<z.ZodString>;
6
+ revision: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>>;
8
+ export declare const applicationTargetSchema: z.ZodOptional<z.ZodObject<{
9
+ repository: z.ZodOptional<z.ZodString>;
10
+ revision: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>>;
12
+ export declare const requestAuthorization: AsyncLocalStorage<string>;
13
+ export declare const ASSISTANT_INSTRUCTION = "Report the headline and the one recommended change. Include the share link. If the headline says the finding is gone, say that. If there are no findings, say that this does not prove the system is secure. Do not paste source, diffs, or account details. Do not invent a link when the share link is missing.";
14
+ type Target = {
15
+ repository?: string;
16
+ path?: string;
17
+ revision?: string;
18
+ };
19
+ type SaveInput = {
20
+ tool: string;
21
+ target?: Target | null;
22
+ submittedPaths: string[];
23
+ };
24
+ export declare function buildProjection(input: SaveInput, output: Record<string, unknown>): {
25
+ tool: string;
26
+ engineVersion: string;
27
+ target: Target | null;
28
+ riskSentence: unknown;
29
+ findings: Record<string, unknown>[];
30
+ truncated: boolean;
31
+ checked: string[];
32
+ skipped: string[];
33
+ submittedPaths: string[];
34
+ };
35
+ export declare function withSavedReview(output: Record<string, unknown>, input: SaveInput): Promise<Record<string, unknown>>;
36
+ export {};
@@ -0,0 +1,177 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { z } from "zod";
3
+ import { getApiAccessToken } from "./auth.js";
4
+ import { getApiUrl } from "./config.js";
5
+ import { VERSION } from "./version.js";
6
+ const repositoryLabel = z
7
+ .string()
8
+ .regex(/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/)
9
+ .max(200);
10
+ const relativePath = z
11
+ .string()
12
+ .min(1)
13
+ .max(500)
14
+ .refine((path) => !path.startsWith("/") && !path.includes("..") && !path.includes("\\"));
15
+ const revision = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/);
16
+ export const savedTargetSchema = z
17
+ .object({
18
+ repository: repositoryLabel.optional(),
19
+ path: relativePath.optional(),
20
+ revision: revision.optional(),
21
+ })
22
+ .strict()
23
+ .optional()
24
+ .describe("Repository label for the next comparison. The server does not fetch it.");
25
+ export const applicationTargetSchema = z
26
+ .object({
27
+ repository: repositoryLabel.optional(),
28
+ revision: revision.optional(),
29
+ })
30
+ .strict()
31
+ .optional()
32
+ .describe("Repository label for the next comparison. Send paths on each file.");
33
+ export const requestAuthorization = new AsyncLocalStorage();
34
+ export const ASSISTANT_INSTRUCTION = "Report the headline and the one recommended change. Include the share link. If the headline says the finding is gone, say that. If there are no findings, say that this does not prove the system is secure. Do not paste source, diffs, or account details. Do not invent a link when the share link is missing.";
35
+ const refused = {
36
+ saved: false,
37
+ id: null,
38
+ ownerUrl: null,
39
+ shareUrl: null,
40
+ shareExpiresAt: null,
41
+ comparison: null,
42
+ headline: null,
43
+ assistantInstruction: ASSISTANT_INSTRUCTION,
44
+ };
45
+ function clip(value, max = 200) {
46
+ if (typeof value !== "string")
47
+ return null;
48
+ const text = value.replaceAll("\n", " ").trim();
49
+ if (!text || text.length > max)
50
+ return null;
51
+ return text;
52
+ }
53
+ function findingPath(finding, fallback) {
54
+ const location = finding.location;
55
+ if (location && typeof location === "object" && "path" in location && fallback) {
56
+ return fallback;
57
+ }
58
+ const evidence = finding.evidence;
59
+ if (Array.isArray(evidence) && evidence[0] && typeof evidence[0] === "object" && "file" in evidence[0]) {
60
+ const file = clip(evidence[0].file, 500);
61
+ if (file)
62
+ return file;
63
+ }
64
+ return fallback;
65
+ }
66
+ export function buildProjection(input, output) {
67
+ const findings = Array.isArray(output.findings) ? output.findings : [];
68
+ const fallbackPath = input.target?.path ?? "";
69
+ const projected = findings.slice(0, 100).flatMap((item) => {
70
+ if (!item || typeof item !== "object")
71
+ return [];
72
+ const finding = item;
73
+ const title = clip(finding.title);
74
+ const severity = clip(finding.severity, 16);
75
+ if (!title || !severity)
76
+ return [];
77
+ const row = { title, severity };
78
+ const ruleId = clip(finding.ruleId, 64);
79
+ if (ruleId)
80
+ row.ruleId = ruleId;
81
+ for (const field of ["detail", "resource", "remediation"]) {
82
+ const text = clip(finding[field]);
83
+ if (text)
84
+ row[field] = text;
85
+ }
86
+ const path = findingPath(finding, fallbackPath);
87
+ if (path)
88
+ row.path = path;
89
+ const line = finding.location && typeof finding.location === "object" && "line" in finding.location
90
+ ? finding.location.line
91
+ : Array.isArray(finding.evidence) && finding.evidence[0] && typeof finding.evidence[0] === "object"
92
+ ? finding.evidence[0].line
93
+ : null;
94
+ if (typeof line === "number" && Number.isInteger(line) && line > 0)
95
+ row.line = line;
96
+ return [row];
97
+ });
98
+ const checked = Array.isArray(output.rulesEvaluated)
99
+ ? output.rulesEvaluated.flatMap((rule) => {
100
+ const text = clip(rule, 64);
101
+ return text ? [text] : [];
102
+ })
103
+ : [];
104
+ if (input.tool === "review_application_architecture") {
105
+ checked.unshift(`Reviewed ${input.submittedPaths.length} files.`);
106
+ checked.push(...input.submittedPaths);
107
+ }
108
+ const skipped = [];
109
+ if (Array.isArray(output.warnings) && output.warnings.length > 0) {
110
+ const count = output.warnings.length;
111
+ skipped.push(`The scanner reported ${count} ${count === 1 ? "warning" : "warnings"}.`);
112
+ }
113
+ for (const field of ["evidenceGaps", "limitations"]) {
114
+ if (!Array.isArray(output[field]))
115
+ continue;
116
+ for (const entry of output[field]) {
117
+ const text = clip(entry);
118
+ if (text)
119
+ skipped.push(text);
120
+ if (skipped.length >= 20)
121
+ break;
122
+ }
123
+ }
124
+ const summary = clip(output.summary);
125
+ const riskSentence = summary
126
+ ?? (projected.length === 0
127
+ ? "No findings. That does not prove the system is secure."
128
+ : projected[0].title);
129
+ return {
130
+ tool: input.tool,
131
+ engineVersion: VERSION,
132
+ target: input.target ?? null,
133
+ riskSentence,
134
+ findings: projected,
135
+ truncated: output.truncated === true || findings.length > 100,
136
+ checked: checked.length > 0 ? checked.slice(0, 20) : ["Individual checks were not recorded."],
137
+ skipped: skipped.length > 0 ? skipped.slice(0, 20) : ["Nothing was recorded as skipped."],
138
+ submittedPaths: input.submittedPaths.length > 0 ? input.submittedPaths : [fallbackPath],
139
+ };
140
+ }
141
+ async function authorizationHeader() {
142
+ const fromRequest = requestAuthorization.getStore();
143
+ if (fromRequest)
144
+ return fromRequest;
145
+ const token = await getApiAccessToken();
146
+ return token ? `Bearer ${token}` : null;
147
+ }
148
+ export async function withSavedReview(output, input) {
149
+ const projection = buildProjection(input, output);
150
+ let savedReview = refused;
151
+ const authorization = await authorizationHeader();
152
+ if (authorization) {
153
+ try {
154
+ const response = await fetch(`${getApiUrl()}/api/mcp/reviews`, {
155
+ method: "POST",
156
+ headers: {
157
+ Authorization: authorization,
158
+ Accept: "application/json",
159
+ "Content-Type": "application/json",
160
+ },
161
+ body: JSON.stringify(projection),
162
+ signal: AbortSignal.timeout(10_000),
163
+ redirect: "error",
164
+ cache: "no-store",
165
+ });
166
+ if (response.ok) {
167
+ const body = await response.json();
168
+ if (body.savedReview)
169
+ savedReview = body.savedReview;
170
+ }
171
+ }
172
+ catch {
173
+ savedReview = refused;
174
+ }
175
+ }
176
+ return { ...output, savedReview };
177
+ }
package/dist/server.js CHANGED
@@ -6,6 +6,7 @@ import { looksLikeIac, ScanInputError } from "./iac/parse.js";
6
6
  import { filterFindings, scanIac, summarize } from "./iac/scan.js";
7
7
  import { registerConsultation } from "./application/tools.js";
8
8
  import { presentReview } from "./review-edits.js";
9
+ import { savedTargetSchema, withSavedReview } from "./saved-review.js";
9
10
  import { VERSION } from "./version.js";
10
11
  const contentSchema = z
11
12
  .string()
@@ -93,8 +94,9 @@ export function createServer(remote = false) {
93
94
  .optional()
94
95
  .default("auto")
95
96
  .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, serverless, iam, package, application, auto."),
97
+ target: savedTargetSchema,
96
98
  },
97
- }, async ({ content, focus, format, minSeverity, maxFindings }) => {
99
+ }, async ({ content, focus, format, minSeverity, maxFindings, target }) => {
98
100
  const botHints = fetchPublicBotHints();
99
101
  if (!remote) {
100
102
  const auth = await requireApiAuth();
@@ -103,15 +105,20 @@ export function createServer(remote = false) {
103
105
  }
104
106
  try {
105
107
  const options = { focus, minSeverity, maxFindings };
108
+ const save = {
109
+ tool: "review_architecture",
110
+ target: target ?? null,
111
+ submittedPaths: [target?.path ?? ""],
112
+ };
106
113
  if (format !== "application" &&
107
114
  (format !== "auto" || looksLikeIac(content))) {
108
- return result(presentReview(content, {
115
+ return result(await withSavedReview(presentReview(content, {
109
116
  ...scanIac(content, { ...options, format }),
110
117
  botHints: await botHints,
111
- }));
118
+ }), save));
112
119
  }
113
120
  const findings = filterFindings(architectureFindings(content, focus).slice(1), options);
114
- return result(presentReview(content, {
121
+ return result(await withSavedReview(presentReview(content, {
115
122
  status: "completed",
116
123
  engineVersion: VERSION,
117
124
  format: "application",
@@ -125,7 +132,7 @@ export function createServer(remote = false) {
125
132
  "Infrastructure bottleneck checks cover a disabled timeout, a one-connection pool, and an unbounded retry limit.",
126
133
  ],
127
134
  botHints: await botHints,
128
- }));
135
+ }), save));
129
136
  }
130
137
  catch (error) {
131
138
  return inputError(error);
@@ -172,15 +179,20 @@ export function createServer(remote = false) {
172
179
  ])
173
180
  .default("auto"),
174
181
  ...filters,
182
+ target: savedTargetSchema,
175
183
  },
176
- }, async ({ content, format, focus, minSeverity, maxFindings }) => {
184
+ }, async ({ content, format, focus, minSeverity, maxFindings, target }) => {
177
185
  if (!remote) {
178
186
  const auth = await requireApiAuth();
179
187
  if (!auth.ok)
180
188
  return unauthorizedContent(auth);
181
189
  }
182
190
  try {
183
- return result(presentReview(content, scanIac(content, { format, focus, minSeverity, maxFindings })));
191
+ return result(await withSavedReview(presentReview(content, scanIac(content, { format, focus, minSeverity, maxFindings })), {
192
+ tool: name,
193
+ target: target ?? null,
194
+ submittedPaths: [target?.path ?? ""],
195
+ }));
184
196
  }
185
197
  catch (error) {
186
198
  return inputError(error);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.17";
1
+ export declare const VERSION = "0.6.19";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.17";
1
+ export const VERSION = "0.6.19";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"