@skaleagents/swarm 0.2.2 → 0.3.1

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
@@ -1,7 +1,7 @@
1
1
  # @skaleagents/swarm
2
2
 
3
3
  Public stdio MCP server for SkaleAgents Phase 1. Talks to the Laravel **api**
4
- (JSON only, no web UI) with a Sanctum bearer token.
4
+ over JSON and uses browser OAuth for sign-in.
5
5
 
6
6
  Tools: `review_architecture`, `scan_iac_stub`.
7
7
 
@@ -11,14 +11,13 @@ the MCP tool for a structured review.
11
11
 
12
12
  ## Prerequisites
13
13
 
14
- 1. **API running:** Sail on `http://localhost:8082` (or your hosted API URL later).
15
- 2. **Bearer token:** mint one in the web app: sign in → **MCP** → Create token.
16
- Or for local-only testing:
17
- ```bash
18
- curl -s -X POST http://localhost:8082/api/auth/google/callback \
19
- -H 'Content-Type: application/json' \
20
- -d '{"code":"mcp","displayName":"MCP User","email":"mcp@example.com"}' | jq -r .token
21
- ```
14
+ 1. Node.js 20 or newer. The client connects to `https://api.skaleagents.com` by default.
15
+ 2. A browser that can open the SkaleAgents sign-in page.
16
+
17
+ The first tool call opens browser sign-in. Approve MCP access there and return
18
+ to your AI client. The package stores the OAuth refresh credential locally and
19
+ refreshes access automatically. You do not need to create or paste an API key.
20
+ Active connections can be revoked from the web app's MCP settings page.
22
21
 
23
22
  ## Local development
24
23
 
@@ -27,7 +26,6 @@ git clone https://github.com/SkaleAgents/mcp-server.git
27
26
  cd mcp-server
28
27
  npm install
29
28
  cp .env.example .env
30
- # Set SKALEAGENTS_API_TOKEN=<token from web app or curl above>
31
29
  npm run build
32
30
  npm test
33
31
  npm run smoke # needs API on :8082
@@ -46,7 +44,6 @@ Add to `.cursor/mcp.json` (project) or Cursor Settings → MCP:
46
44
  "command": "node",
47
45
  "args": ["/absolute/path/to/mcp-server/dist/index.js"],
48
46
  "env": {
49
- "SKALEAGENTS_API_TOKEN": "<paste token from web app → API tokens>",
50
47
  "PLATFORM_API_URL": "http://localhost:8082"
51
48
  }
52
49
  }
@@ -63,7 +60,6 @@ Dev without build:
63
60
  "command": "npx",
64
61
  "args": ["tsx", "/absolute/path/to/mcp-server/src/index.ts"],
65
62
  "env": {
66
- "SKALEAGENTS_API_TOKEN": "<token>",
67
63
  "PLATFORM_API_URL": "http://localhost:8082"
68
64
  }
69
65
  }
@@ -71,18 +67,14 @@ Dev without build:
71
67
  }
72
68
  ```
73
69
 
74
- ### Option B: after npm publish (hosted API)
70
+ ### Option B: published package (hosted API)
75
71
 
76
72
  ```json
77
73
  {
78
74
  "mcpServers": {
79
75
  "skaleagents": {
80
76
  "command": "npx",
81
- "args": ["-y", "@skaleagents/swarm"],
82
- "env": {
83
- "SKALEAGENTS_API_TOKEN": "<token>",
84
- "PLATFORM_API_URL": "https://api.skaleagents.com"
85
- }
77
+ "args": ["-y", "@skaleagents/swarm@0.3.1"]
86
78
  }
87
79
  }
88
80
  }
@@ -92,18 +84,22 @@ Restart Cursor after saving. In Agent/Chat, tools should appear as `review_archi
92
84
 
93
85
  ## Claude Code
94
86
 
95
- Same env vars; point `command`/`args` at `node …/dist/index.js` or `npx @skaleagents/swarm` once published.
87
+ Use the published package configuration above in `.mcp.json`. OAuth starts on the first tool call. No API URL or keys are needed.
96
88
 
97
89
  ## Environment
98
90
 
99
91
  | Variable | Required | Description |
100
92
  |----------|----------|-------------|
101
- | `SKALEAGENTS_API_TOKEN` | Yes | Sanctum bearer token (from the web **MCP** page) |
102
- | `PLATFORM_API_URL` | No | Default `http://localhost:8082` |
93
+ | `SKALEAGENTS_API_TOKEN` | No | Legacy Sanctum bearer-token override. OAuth is used when empty. |
94
+ | `PLATFORM_API_URL` | No | Defaults to `https://api.skaleagents.com`. Override only for local development or another API deployment. Empty values use the default. |
95
+ | `SKALEAGENTS_OAUTH_CACHE` | No | OAuth cache path. Default `~/.config/skaleagents/oauth.json`. |
96
+ | `SKALEAGENTS_OAUTH_ENABLED` | No | Set to `false` only to disable browser OAuth. |
103
97
 
104
98
  ## Auth behavior
105
99
 
106
- - Missing/invalid token → tools return an **unauthorized** error (fail closed).
107
- - Token is user-scoped; bot visibility follows `api` RBAC.
100
+ - Missing bearer override → browser OAuth starts automatically.
101
+ - OAuth access and refresh credentials are stored with local-user-only file permissions.
102
+ - A rejected or revoked connection returns an auth error and does not run a tool.
103
+ - The connection is user-scoped; bot visibility follows `api` RBAC.
108
104
 
109
105
  Hub contract: [docs/contracts/mcp/tools.md](https://github.com/SkaleAgents/workspace/blob/main/docs/contracts/mcp/tools.md)
package/dist/auth.d.ts CHANGED
@@ -3,12 +3,14 @@ export type AuthResult = {
3
3
  userId?: string;
4
4
  } | {
5
5
  ok: false;
6
- reason: "missing_token" | "unauthorized" | "api_unavailable";
6
+ reason: "oauth_required" | "oauth_failed" | "unauthorized" | "api_unavailable";
7
7
  };
8
8
  /**
9
- * Require the API to validate the bearer token before running any tool.
9
+ * Validate the API bearer token before running any tool. OAuth starts in the
10
+ * user's browser when no legacy SKALEAGENTS_API_TOKEN is configured.
10
11
  */
11
12
  export declare function requireApiAuth(): Promise<AuthResult>;
13
+ export declare function getApiAccessToken(): Promise<string | null>;
12
14
  export declare function unauthorizedContent(reason: AuthResult & {
13
15
  ok: false;
14
16
  }): {
package/dist/auth.js CHANGED
@@ -1,19 +1,31 @@
1
- import { getApiToken, getApiUrl } from "./config.js";
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import { getApiToken, getApiUrl, getOAuthCachePath, isOAuthEnabled } from "./config.js";
7
+ let cachedCredentials = null;
8
+ let authenticationInFlight = null;
2
9
  /**
3
- * Require the API to validate the bearer token before running any tool.
10
+ * Validate the API bearer token before running any tool. OAuth starts in the
11
+ * user's browser when no legacy SKALEAGENTS_API_TOKEN is configured.
4
12
  */
5
13
  export async function requireApiAuth() {
6
- const token = getApiToken();
14
+ const token = await getAccessToken();
7
15
  if (!token) {
8
- return { ok: false, reason: "missing_token" };
16
+ return {
17
+ ok: false,
18
+ reason: isOAuthEnabled() ? "oauth_failed" : "oauth_required",
19
+ };
9
20
  }
10
21
  try {
11
- const res = await fetch(`${getApiUrl()}/api/user`, {
12
- headers: {
13
- Accept: "application/json",
14
- Authorization: `Bearer ${token}`,
15
- },
16
- });
22
+ let res = await fetchApiUser(token);
23
+ if ((res.status === 401 || res.status === 403) && !getApiToken()) {
24
+ const replacementToken = await reauthorizeAfterRejection();
25
+ if (replacementToken) {
26
+ res = await fetchApiUser(replacementToken);
27
+ }
28
+ }
17
29
  if (res.status === 401 || res.status === 403) {
18
30
  return { ok: false, reason: "unauthorized" };
19
31
  }
@@ -27,14 +39,247 @@ export async function requireApiAuth() {
27
39
  return { ok: false, reason: "api_unavailable" };
28
40
  }
29
41
  }
42
+ export async function getApiAccessToken() {
43
+ return getAccessToken();
44
+ }
30
45
  export function unauthorizedContent(reason) {
31
- const text = reason.reason === "missing_token"
32
- ? "unauthorized: set SKALEAGENTS_API_TOKEN"
33
- : reason.reason === "unauthorized"
34
- ? "unauthorized: invalid SKALEAGENTS_API_TOKEN"
35
- : "api unavailable: could not validate SKALEAGENTS_API_TOKEN";
46
+ const text = reason.reason === "oauth_required"
47
+ ? "MCP OAuth is disabled. Set SKALEAGENTS_API_TOKEN or enable OAuth."
48
+ : reason.reason === "oauth_failed"
49
+ ? "MCP sign-in could not be completed. Check the browser window and try again."
50
+ : reason.reason === "unauthorized"
51
+ ? "unauthorized: the SkaleAgents connection was rejected"
52
+ : "api unavailable: could not validate the SkaleAgents connection";
36
53
  return {
37
54
  isError: true,
38
55
  content: [{ type: "text", text }],
39
56
  };
40
57
  }
58
+ async function getAccessToken() {
59
+ const legacyToken = getApiToken();
60
+ if (legacyToken)
61
+ return legacyToken;
62
+ if (!isOAuthEnabled())
63
+ return null;
64
+ if (!authenticationInFlight) {
65
+ authenticationInFlight = getOAuthAccessToken().finally(() => {
66
+ authenticationInFlight = null;
67
+ });
68
+ }
69
+ try {
70
+ return await authenticationInFlight;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ async function fetchApiUser(token) {
77
+ return fetch(`${getApiUrl()}/api/user`, {
78
+ headers: {
79
+ Accept: "application/json",
80
+ Authorization: `Bearer ${token}`,
81
+ },
82
+ });
83
+ }
84
+ async function reauthorizeAfterRejection() {
85
+ cachedCredentials = null;
86
+ await removeCredentials();
87
+ return getAccessToken();
88
+ }
89
+ async function getOAuthAccessToken() {
90
+ const apiUrl = getApiUrl();
91
+ const metadata = await getMetadata(apiUrl);
92
+ const stored = await readCredentials(apiUrl);
93
+ if (stored && stored.expiresAt > Date.now() + 30_000) {
94
+ cachedCredentials = stored;
95
+ return stored.accessToken;
96
+ }
97
+ if (stored?.refreshToken) {
98
+ try {
99
+ const refreshed = await exchangeRefreshToken(metadata.token_endpoint, stored.refreshToken);
100
+ await saveCredentials(apiUrl, refreshed);
101
+ return refreshed.accessToken;
102
+ }
103
+ catch {
104
+ cachedCredentials = null;
105
+ await removeCredentials();
106
+ }
107
+ }
108
+ const authorizationCode = await authorizeInBrowser(metadata.authorization_endpoint);
109
+ const exchanged = await exchangeAuthorizationCode(metadata.token_endpoint, authorizationCode.code, authorizationCode.redirectUri, authorizationCode.verifier);
110
+ await saveCredentials(apiUrl, exchanged);
111
+ return exchanged.accessToken;
112
+ }
113
+ async function getMetadata(apiUrl) {
114
+ const response = await fetch(`${apiUrl}/.well-known/oauth-authorization-server`, {
115
+ headers: { Accept: "application/json" },
116
+ });
117
+ if (!response.ok) {
118
+ throw new Error(`OAuth metadata request failed with status ${response.status}`);
119
+ }
120
+ const metadata = (await response.json());
121
+ if (typeof metadata.authorization_endpoint !== "string" ||
122
+ typeof metadata.token_endpoint !== "string") {
123
+ throw new Error("OAuth metadata is incomplete");
124
+ }
125
+ return metadata;
126
+ }
127
+ async function authorizeInBrowser(authorizationEndpoint) {
128
+ const verifier = randomBytes(48).toString("base64url");
129
+ const challenge = createCodeChallenge(verifier);
130
+ const state = randomBytes(32).toString("base64url");
131
+ return new Promise((resolve, reject) => {
132
+ let settled = false;
133
+ let redirectUri = "";
134
+ const server = createServer((request, response) => {
135
+ const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
136
+ if (requestUrl.pathname !== "/oauth/callback") {
137
+ response.writeHead(404);
138
+ response.end("Not found");
139
+ return;
140
+ }
141
+ const receivedState = requestUrl.searchParams.get("state");
142
+ const error = requestUrl.searchParams.get("error");
143
+ const code = requestUrl.searchParams.get("code");
144
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
145
+ response.end("<!doctype html><title>SkaleAgents connected</title>" +
146
+ "<p>You can close this window and return to your AI client.</p>");
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ server.close();
151
+ if (receivedState !== state) {
152
+ reject(new Error("OAuth state validation failed"));
153
+ return;
154
+ }
155
+ if (error || !code) {
156
+ reject(new Error("OAuth authorization was denied"));
157
+ return;
158
+ }
159
+ resolve({
160
+ code,
161
+ redirectUri,
162
+ verifier,
163
+ });
164
+ });
165
+ server.once("error", reject);
166
+ server.listen(0, "127.0.0.1", () => {
167
+ const port = addressPort(server);
168
+ redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
169
+ const url = new URL(authorizationEndpoint);
170
+ url.search = new URLSearchParams({
171
+ client_id: "skaleagents-mcp",
172
+ response_type: "code",
173
+ redirect_uri: redirectUri,
174
+ scope: "mcp",
175
+ state,
176
+ code_challenge: challenge,
177
+ code_challenge_method: "S256",
178
+ }).toString();
179
+ console.error("Opening SkaleAgents sign-in in your browser...");
180
+ console.error(url.toString());
181
+ openBrowser(url.toString());
182
+ });
183
+ });
184
+ }
185
+ async function exchangeAuthorizationCode(tokenEndpoint, code, redirectUri, verifier) {
186
+ const response = await fetchToken(tokenEndpoint, {
187
+ grant_type: "authorization_code",
188
+ client_id: "skaleagents-mcp",
189
+ code,
190
+ redirect_uri: redirectUri,
191
+ code_verifier: verifier,
192
+ });
193
+ return parseTokenResponse(response);
194
+ }
195
+ async function exchangeRefreshToken(tokenEndpoint, refreshToken) {
196
+ const response = await fetchToken(tokenEndpoint, {
197
+ grant_type: "refresh_token",
198
+ client_id: "skaleagents-mcp",
199
+ refresh_token: refreshToken,
200
+ });
201
+ return parseTokenResponse(response);
202
+ }
203
+ async function fetchToken(tokenEndpoint, values) {
204
+ const response = await fetch(tokenEndpoint, {
205
+ method: "POST",
206
+ headers: {
207
+ Accept: "application/json",
208
+ "Content-Type": "application/x-www-form-urlencoded",
209
+ },
210
+ body: new URLSearchParams(values),
211
+ });
212
+ const payload = (await response.json().catch(() => ({})));
213
+ if (!response.ok) {
214
+ throw new Error(`OAuth token request failed with status ${response.status}`);
215
+ }
216
+ return payload;
217
+ }
218
+ function parseTokenResponse(response) {
219
+ if (typeof response.access_token !== "string" ||
220
+ typeof response.refresh_token !== "string" ||
221
+ typeof response.expires_in !== "number") {
222
+ throw new Error("OAuth token response is incomplete");
223
+ }
224
+ return {
225
+ apiUrl: getApiUrl(),
226
+ accessToken: response.access_token,
227
+ refreshToken: response.refresh_token,
228
+ expiresAt: Date.now() + response.expires_in * 1000,
229
+ };
230
+ }
231
+ async function readCredentials(apiUrl) {
232
+ if (cachedCredentials?.apiUrl === apiUrl)
233
+ return cachedCredentials;
234
+ try {
235
+ const parsed = JSON.parse(await readFile(getOAuthCachePath(), "utf8"));
236
+ if (parsed.apiUrl !== apiUrl ||
237
+ typeof parsed.accessToken !== "string" ||
238
+ typeof parsed.refreshToken !== "string" ||
239
+ typeof parsed.expiresAt !== "number") {
240
+ return null;
241
+ }
242
+ return parsed;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
248
+ async function saveCredentials(apiUrl, credentials) {
249
+ cachedCredentials = { ...credentials, apiUrl };
250
+ const path = getOAuthCachePath();
251
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
252
+ await writeFile(path, JSON.stringify(cachedCredentials), { encoding: "utf8", mode: 0o600 });
253
+ await chmod(path, 0o600);
254
+ }
255
+ async function removeCredentials() {
256
+ try {
257
+ await unlink(getOAuthCachePath());
258
+ }
259
+ catch {
260
+ /* Cache may not exist. */
261
+ }
262
+ }
263
+ function createCodeChallenge(verifier) {
264
+ return createHash("sha256").update(verifier).digest("base64url");
265
+ }
266
+ function addressPort(server) {
267
+ const address = server.address();
268
+ if (address === null || typeof address === "string") {
269
+ throw new Error("OAuth callback server did not receive a port");
270
+ }
271
+ return address.port;
272
+ }
273
+ function openBrowser(url) {
274
+ const command = process.platform === "darwin"
275
+ ? "open"
276
+ : process.platform === "win32"
277
+ ? "cmd"
278
+ : "xdg-open";
279
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
280
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
281
+ child.once("error", () => {
282
+ console.error("Could not open a browser automatically. Open the URL printed above.");
283
+ });
284
+ child.unref();
285
+ }
package/dist/config.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function getApiUrl(): string;
2
2
  export declare function getApiToken(): string;
3
+ export declare function getOAuthCachePath(): string;
4
+ export declare function isOAuthEnabled(): boolean;
package/dist/config.js CHANGED
@@ -1,6 +1,15 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
1
3
  export function getApiUrl() {
2
- return (process.env.PLATFORM_API_URL?.replace(/\/$/, "") ?? "http://localhost:8082");
4
+ return (process.env.PLATFORM_API_URL?.trim().replace(/\/+$/, "") || "https://api.skaleagents.com");
3
5
  }
4
6
  export function getApiToken() {
5
7
  return process.env.SKALEAGENTS_API_TOKEN?.trim() ?? "";
6
8
  }
9
+ export function getOAuthCachePath() {
10
+ return (process.env.SKALEAGENTS_OAUTH_CACHE?.trim() ??
11
+ join(homedir(), ".config", "skaleagents", "oauth.json"));
12
+ }
13
+ export function isOAuthEnabled() {
14
+ return process.env.SKALEAGENTS_OAUTH_ENABLED !== "false";
15
+ }
package/dist/index.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { requireApiAuth, unauthorizedContent } from "./auth.js";
5
+ import { getApiAccessToken, requireApiAuth, unauthorizedContent, } from "./auth.js";
6
6
  import { architectureFindings, countIacResources, fetchPublicBotHints, } from "./review.js";
7
7
  const server = new McpServer({
8
8
  name: "skaleagents-swarm",
9
- version: "0.2.2",
9
+ version: "0.3.1",
10
10
  });
11
11
  server.registerTool("review_architecture", {
12
12
  title: "Review architecture",
@@ -32,7 +32,10 @@ server.registerTool("review_architecture", {
32
32
  const auth = await requireApiAuth();
33
33
  if (!auth.ok)
34
34
  return unauthorizedContent(auth);
35
- const botHints = await fetchPublicBotHints();
35
+ const token = await getApiAccessToken();
36
+ if (!token)
37
+ return unauthorizedContent({ ok: false, reason: "oauth_failed" });
38
+ const botHints = await fetchPublicBotHints(token);
36
39
  const focusValue = focus ?? "general";
37
40
  const formatValue = format ?? "auto";
38
41
  const output = {
package/dist/review.d.ts CHANGED
@@ -5,5 +5,5 @@ export type Finding = {
5
5
  detail: string;
6
6
  };
7
7
  export declare function architectureFindings(content: string, focus: string): Finding[];
8
- export declare function fetchPublicBotHints(): Promise<string[]>;
8
+ export declare function fetchPublicBotHints(token: string): Promise<string[]>;
9
9
  export declare function countIacResources(content: string): number;
package/dist/review.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getApiToken, getApiUrl } from "./config.js";
1
+ import { getApiUrl } from "./config.js";
2
2
  function shouldInclude(focus, category) {
3
3
  return focus === "general" || focus === category;
4
4
  }
@@ -104,12 +104,12 @@ export function architectureFindings(content, focus) {
104
104
  }
105
105
  return findings;
106
106
  }
107
- export async function fetchPublicBotHints() {
107
+ export async function fetchPublicBotHints(token) {
108
108
  try {
109
109
  const res = await fetch(`${getApiUrl()}/api/bots`, {
110
110
  headers: {
111
111
  Accept: "application/json",
112
- Authorization: `Bearer ${getApiToken()}`,
112
+ Authorization: `Bearer ${token}`,
113
113
  },
114
114
  });
115
115
  if (!res.ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"