forta-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +106 -0
  2. package/index.js +350 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # forta-mcp
2
+
3
+ MCP server for the [Forta](https://github.com/aidenappl/forta-api) OAuth2 identity provider. Gives Claude Code direct access to users, platforms, roles, grants, and token introspection.
4
+
5
+ > **appleby.cloud platform** · MCP server · `auth.appleby.cloud`
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ Forta is the identity provider for the whole `appleby.cloud` ecosystem, so almost every auth bug starts here. This server turns "why is this user getting a 403 from Keyring" into one `forta_list_grants` call instead of a manual database dig.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ npx forta-mcp --setup
17
+ ```
18
+
19
+ Prompts for your Forta API URL and token, writes the config to `~/.mcp.json`. Restart Claude Code afterwards.
20
+
21
+ ## Manual Setup
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "forta": {
27
+ "command": "npx",
28
+ "args": ["-y", "forta-mcp"],
29
+ "env": {
30
+ "FORTA_API_URL": "https://auth.appleby.cloud",
31
+ "FORTA_API_TOKEN": "frt_..."
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Generate a token from the Forta dashboard under **Settings > API Tokens**, or:
39
+
40
+ ```bash
41
+ curl -X POST https://auth.appleby.cloud/admin/api-tokens \
42
+ -H "Authorization: Bearer <access-token>" \
43
+ -H "Content-Type: application/json" \
44
+ -d '{"name":"claude-code","expires_in":"365d"}'
45
+ ```
46
+
47
+ Tokens are `frt_`-prefixed, owned by the creating user, and require that user to be a Forta super-admin for the `/admin` tools. The plaintext is returned once and stored only as a SHA-256 hash.
48
+
49
+ ## Tools
50
+
51
+ ### Health & identity
52
+ | Tool | Description |
53
+ |------|-------------|
54
+ | `forta_health` | API health and database connectivity |
55
+ | `forta_get_self` | Which user this token authenticates as |
56
+
57
+ ### Users
58
+ | Tool | Description |
59
+ |------|-------------|
60
+ | `forta_list_users` | List users with status and super-admin flag |
61
+ | `forta_get_user` | Single user with metadata and linked identities |
62
+ | `forta_update_user` | Update name, email, status, super-admin, password |
63
+ | `forta_delete_user` | Soft-delete a user ⚠️ |
64
+
65
+ ### Platforms
66
+ | Tool | Description |
67
+ |------|-------------|
68
+ | `forta_list_platforms` | All registered OAuth platforms |
69
+ | `forta_get_platform` | Full platform config |
70
+ | `forta_create_platform` | Register a platform (returns `client_secret` once) |
71
+ | `forta_update_platform` | Update platform config |
72
+ | `forta_delete_platform` | Delete a platform ⚠️ |
73
+ | `forta_rotate_platform_credentials` | Rotate `client_secret` ⚠️ |
74
+
75
+ ### Roles
76
+ | Tool | Description |
77
+ |------|-------------|
78
+ | `forta_list_roles` | Roles on a platform, with permissions |
79
+ | `forta_get_role` | Single role |
80
+ | `forta_create_role` | Create a role |
81
+ | `forta_update_role` | Update name or permission set |
82
+ | `forta_delete_role` | Delete a role ⚠️ |
83
+
84
+ ### Grants
85
+ | Tool | Description |
86
+ |------|-------------|
87
+ | `forta_list_grants` | Who can access what, with which role — **start here for 403s** |
88
+ | `forta_create_grant` | Grant a user access to a platform |
89
+ | `forta_revoke_grant` | Revoke a grant ⚠️ |
90
+ | `forta_check_grant` | Check a grant by `client_id`, exactly as a service sees it |
91
+
92
+ ### Tokens
93
+ | Tool | Description |
94
+ |------|-------------|
95
+ | `forta_introspect_token` | RFC 7662 introspection — is this token expired, or bound elsewhere? |
96
+ | `forta_list_api_tokens` | Active API tokens with expiry and last use |
97
+ | `forta_create_api_token` | Mint a token (plaintext returned once) |
98
+ | `forta_revoke_api_token` | Revoke a token ⚠️ |
99
+
100
+ ⚠️ = destructive. Tool descriptions state the blast radius; `rotate_platform_credentials` in particular takes the platform down until its deployed config is updated.
101
+
102
+ ## Contributing & further reading
103
+
104
+ - [forta-api](https://github.com/aidenappl/forta-api) — the API this wraps
105
+ - [go-forta](https://github.com/aidenappl/go-forta) — Go SDK; v1.3.0+ accepts these tokens
106
+ - [keyring-mcp](https://github.com/aidenappl/keyring-mcp) — uses the same `frt_` token
package/index.js ADDED
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { createInterface } from "readline";
6
+ import { readFileSync, writeFileSync, existsSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+
10
+ // --- Interactive setup ---
11
+
12
+ if (process.argv.includes("--setup")) {
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
15
+
16
+ console.log("\n Forta MCP Setup\n");
17
+
18
+ const apiUrl = (await ask(" Forta API URL (https://auth.appleby.cloud): ")).trim() || "https://auth.appleby.cloud";
19
+ const apiToken = (await ask(" Forta API Token (frt_…): ")).trim();
20
+ rl.close();
21
+
22
+ if (!apiToken) {
23
+ console.error("\n Error: API token is required.\n");
24
+ process.exit(1);
25
+ }
26
+ if (!apiToken.startsWith("frt_")) {
27
+ console.error("\n Error: Forta API tokens start with 'frt_'. Generate one from the Forta");
28
+ console.error(" dashboard under Settings > API Tokens, or POST /admin/api-tokens.\n");
29
+ process.exit(1);
30
+ }
31
+
32
+ const mcpPath = join(homedir(), ".mcp.json");
33
+ let config = { mcpServers: {} };
34
+ if (existsSync(mcpPath)) {
35
+ try { config = JSON.parse(readFileSync(mcpPath, "utf-8")); } catch {}
36
+ if (!config.mcpServers) config.mcpServers = {};
37
+ }
38
+
39
+ config.mcpServers.forta = {
40
+ command: "npx",
41
+ args: ["-y", "forta-mcp"],
42
+ env: {
43
+ FORTA_API_URL: apiUrl,
44
+ FORTA_API_TOKEN: apiToken,
45
+ },
46
+ };
47
+
48
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
49
+ console.log(`\n Written to ${mcpPath}`);
50
+ console.log(" Restart Claude Code to load the Forta MCP server.\n");
51
+ process.exit(0);
52
+ }
53
+
54
+ // --- MCP Server ---
55
+
56
+ const API_URL = process.env.FORTA_API_URL;
57
+ const API_TOKEN = process.env.FORTA_API_TOKEN;
58
+
59
+ if (!API_URL || !API_TOKEN) {
60
+ console.error("FORTA_API_URL and FORTA_API_TOKEN are required.");
61
+ console.error("Run `npx forta-mcp --setup` to configure.");
62
+ process.exit(1);
63
+ }
64
+
65
+ // --- HTTP helper ---
66
+
67
+ async function api(method, path, params, body) {
68
+ const url = new URL(path, API_URL);
69
+ if (params) {
70
+ for (const [k, v] of Object.entries(params)) {
71
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
72
+ }
73
+ }
74
+ const opts = {
75
+ method,
76
+ headers: {
77
+ Authorization: `Bearer ${API_TOKEN}`,
78
+ },
79
+ signal: AbortSignal.timeout(30000),
80
+ };
81
+ if (body) {
82
+ opts.headers["Content-Type"] = "application/json";
83
+ opts.body = JSON.stringify(body);
84
+ }
85
+ try {
86
+ const res = await fetch(url.toString(), opts);
87
+ const text = await res.text();
88
+ if (!text) return { success: res.ok, status: res.status };
89
+ try {
90
+ return JSON.parse(text);
91
+ } catch {
92
+ // /healthcheck and the OAuth endpoints return bare text, not the envelope.
93
+ return { success: res.ok, status: res.status, body: text };
94
+ }
95
+ } catch (err) {
96
+ return { success: false, error: err.message, error_message: `API request failed: ${err.message}` };
97
+ }
98
+ }
99
+
100
+ function text(data) {
101
+ return [{ type: "text", text: JSON.stringify(data, null, 2) }];
102
+ }
103
+
104
+ // Strips undefined keys so a PUT only sends the fields the caller supplied —
105
+ // the API treats a present-but-null field as an explicit clear.
106
+ function body(obj) {
107
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
108
+ }
109
+
110
+ const server = new McpServer({
111
+ name: "forta",
112
+ version: "1.0.0",
113
+ });
114
+
115
+ // --- Health & identity ---
116
+
117
+ server.tool("forta_health", "Check Forta API health and database connectivity", {}, async () => {
118
+ const res = await api("GET", "/healthcheck");
119
+ return { content: text(res) };
120
+ });
121
+
122
+ server.tool("forta_get_self", "Get the user this API token authenticates as — useful for confirming the token works and which account it belongs to", {}, async () => {
123
+ const res = await api("GET", "/auth/self");
124
+ return { content: text(res) };
125
+ });
126
+
127
+ // --- Users ---
128
+
129
+ server.tool("forta_list_users", "List all Forta users (id, uuid, email, status, is_super_admin, last_login_at)", {
130
+ limit: z.number().optional().describe("Max users to return"),
131
+ offset: z.number().optional().describe("Pagination offset"),
132
+ }, async ({ limit, offset }) => {
133
+ const res = await api("GET", "/admin/users", { limit, offset });
134
+ return { content: text(res) };
135
+ });
136
+
137
+ server.tool("forta_get_user", "Get a single user by ID, including metadata and linked identities", {
138
+ id: z.number().describe("User ID"),
139
+ }, async ({ id }) => {
140
+ const res = await api("GET", `/admin/users/${id}`);
141
+ return { content: text(res) };
142
+ });
143
+
144
+ server.tool("forta_update_user", "Update a user. Only the fields you pass are changed. Setting status to 'suspended' or 'disabled' immediately blocks new logins across every platform", {
145
+ id: z.number().describe("User ID"),
146
+ name: z.string().optional().describe("Full name"),
147
+ display_name: z.string().optional().describe("Display name"),
148
+ email: z.string().optional().describe("Email address (normalized to lowercase)"),
149
+ email_verified: z.boolean().optional().describe("Mark the email verified"),
150
+ profile_image_url: z.string().optional().describe("Profile image URL"),
151
+ status: z.enum(["active", "invited", "suspended", "disabled", "deleted"]).optional().describe("Account status"),
152
+ is_super_admin: z.boolean().optional().describe("Grant or revoke Forta super-admin"),
153
+ password: z.string().optional().describe("Set a new password for the user's local identity"),
154
+ }, async ({ id, ...fields }) => {
155
+ const res = await api("PUT", `/admin/users/${id}`, null, body(fields));
156
+ return { content: text(res) };
157
+ });
158
+
159
+ server.tool("forta_delete_user", "Soft-delete a user (sets deleted_at). The user can no longer authenticate to any platform. Destructive — confirm the user ID first with forta_get_user", {
160
+ id: z.number().describe("User ID"),
161
+ }, async ({ id }) => {
162
+ const res = await api("DELETE", `/admin/users/${id}`);
163
+ return { content: text(res) };
164
+ });
165
+
166
+ // --- Platforms ---
167
+
168
+ server.tool("forta_list_platforms", "List all registered OAuth platforms (client_id, base_url, type, status)", {}, async () => {
169
+ const res = await api("GET", "/admin/platforms");
170
+ return { content: text(res) };
171
+ });
172
+
173
+ server.tool("forta_get_platform", "Get a platform's full config: redirect URIs, allowed origins, category, health check", {
174
+ id: z.number().describe("Platform ID"),
175
+ }, async ({ id }) => {
176
+ const res = await api("GET", `/admin/platforms/${id}`);
177
+ return { content: text(res) };
178
+ });
179
+
180
+ server.tool("forta_create_platform", "Register a new OAuth platform. Returns the client_secret exactly once — it is not retrievable afterwards", {
181
+ name: z.string().describe("Platform name (must be unique)"),
182
+ base_url: z.string().describe("Base URL of the application"),
183
+ platform_type: z.enum(["WEB", "SPA", "SERVICE", "MOBILE"]).describe("Platform type"),
184
+ description: z.string().optional().describe("Description"),
185
+ redirect_uris: z.array(z.string()).optional().describe("Allowed OAuth redirect URIs"),
186
+ logout_redirect_uris: z.array(z.string()).optional().describe("Allowed post-logout redirect URIs"),
187
+ allowed_origins: z.array(z.string()).optional().describe("Allowed CORS origins"),
188
+ is_first_party: z.boolean().optional().describe("First-party app (skips the consent screen)"),
189
+ internal_resource: z.boolean().optional().describe("Internal resource, hidden from the app launcher"),
190
+ platform_icon_url: z.string().optional().describe("Icon URL"),
191
+ host: z.string().optional().describe("Host"),
192
+ position: z.number().optional().describe("Sort position in the launcher"),
193
+ category: z.string().optional().describe("Launcher category"),
194
+ health_check: z.string().optional().describe("Health check URL"),
195
+ }, async (args) => {
196
+ const res = await api("POST", "/admin/platforms", null, body(args));
197
+ return { content: text(res) };
198
+ });
199
+
200
+ server.tool("forta_update_platform", "Update a platform's configuration. Only the fields you pass are changed", {
201
+ id: z.number().describe("Platform ID"),
202
+ name: z.string().optional(),
203
+ description: z.string().optional(),
204
+ base_url: z.string().optional(),
205
+ platform_type: z.enum(["WEB", "SPA", "SERVICE", "MOBILE"]).optional(),
206
+ redirect_uris: z.array(z.string()).optional(),
207
+ logout_redirect_uris: z.array(z.string()).optional(),
208
+ allowed_origins: z.array(z.string()).optional(),
209
+ is_first_party: z.boolean().optional(),
210
+ internal_resource: z.boolean().optional(),
211
+ platform_icon_url: z.string().optional(),
212
+ host: z.string().optional(),
213
+ position: z.number().optional(),
214
+ category: z.string().optional(),
215
+ health_check: z.string().optional(),
216
+ status: z.enum(["active", "disabled"]).optional().describe("Disabling blocks all new logins to this platform"),
217
+ }, async ({ id, ...fields }) => {
218
+ const res = await api("PUT", `/admin/platforms/${id}`, null, body(fields));
219
+ return { content: text(res) };
220
+ });
221
+
222
+ server.tool("forta_delete_platform", "Delete a platform. Every grant and role attached to it stops working and all its users lose access. Destructive — confirm with forta_get_platform first", {
223
+ id: z.number().describe("Platform ID"),
224
+ }, async ({ id }) => {
225
+ const res = await api("DELETE", `/admin/platforms/${id}`);
226
+ return { content: text(res) };
227
+ });
228
+
229
+ server.tool("forta_rotate_platform_credentials", "Rotate a platform's client_secret. The old secret stops working immediately — the platform is down until its deployed config is updated. Returns the new secret once", {
230
+ id: z.number().describe("Platform ID"),
231
+ }, async ({ id }) => {
232
+ const res = await api("POST", `/admin/platforms/${id}/rotate-credentials`);
233
+ return { content: text(res) };
234
+ });
235
+
236
+ // --- Roles ---
237
+
238
+ server.tool("forta_list_roles", "List the roles defined for a platform, with their permission sets", {
239
+ platform_id: z.number().describe("Platform ID"),
240
+ }, async ({ platform_id }) => {
241
+ const res = await api("GET", `/admin/platforms/${platform_id}/roles`);
242
+ return { content: text(res) };
243
+ });
244
+
245
+ server.tool("forta_get_role", "Get a single role by ID", {
246
+ platform_id: z.number().describe("Platform ID"),
247
+ role_id: z.number().describe("Role ID"),
248
+ }, async ({ platform_id, role_id }) => {
249
+ const res = await api("GET", `/admin/platforms/${platform_id}/roles/${role_id}`);
250
+ return { content: text(res) };
251
+ });
252
+
253
+ server.tool("forta_create_role", "Create a role on a platform", {
254
+ platform_id: z.number().describe("Platform ID"),
255
+ name: z.string().describe("Role name"),
256
+ permissions: z.array(z.string()).optional().describe("Permission strings granted by this role"),
257
+ }, async ({ platform_id, name, permissions }) => {
258
+ const res = await api("POST", `/admin/platforms/${platform_id}/roles`, null, body({ name, permissions }));
259
+ return { content: text(res) };
260
+ });
261
+
262
+ server.tool("forta_update_role", "Update a role's name or permissions. Changes apply to every user holding the role", {
263
+ platform_id: z.number().describe("Platform ID"),
264
+ role_id: z.number().describe("Role ID"),
265
+ name: z.string().optional().describe("New role name"),
266
+ permissions: z.array(z.string()).optional().describe("Replacement permission set"),
267
+ }, async ({ platform_id, role_id, name, permissions }) => {
268
+ const res = await api("PUT", `/admin/platforms/${platform_id}/roles/${role_id}`, null, body({ name, permissions }));
269
+ return { content: text(res) };
270
+ });
271
+
272
+ server.tool("forta_delete_role", "Delete a role. Users holding it lose its permissions. Destructive", {
273
+ platform_id: z.number().describe("Platform ID"),
274
+ role_id: z.number().describe("Role ID"),
275
+ }, async ({ platform_id, role_id }) => {
276
+ const res = await api("DELETE", `/admin/platforms/${platform_id}/roles/${role_id}`);
277
+ return { content: text(res) };
278
+ });
279
+
280
+ // --- Grants ---
281
+
282
+ server.tool("forta_list_grants", "List access grants — which users can access which platforms, and with what role. Filter by platform_id or user_id; omit both to list all. This is the first place to look when a user gets a 403 from an appleby.cloud service", {
283
+ platform_id: z.number().optional().describe("Filter to one platform"),
284
+ user_id: z.number().optional().describe("Filter to one user"),
285
+ }, async ({ platform_id, user_id }) => {
286
+ const res = await api("GET", "/admin/grants", { platform_id, user_id });
287
+ return { content: text(res) };
288
+ });
289
+
290
+ server.tool("forta_create_grant", "Grant a user access to a platform with a specific role", {
291
+ user_id: z.number().describe("User ID"),
292
+ platform_id: z.number().describe("Platform ID"),
293
+ role_id: z.number().describe("Role ID to assign"),
294
+ }, async ({ user_id, platform_id, role_id }) => {
295
+ const res = await api("POST", "/admin/grants", null, { user_id, platform_id, role_id });
296
+ return { content: text(res) };
297
+ });
298
+
299
+ server.tool("forta_revoke_grant", "Revoke a grant, removing a user's access to a platform. Takes effect within the platform's grant cache TTL (typically 30s)", {
300
+ id: z.number().describe("Grant ID (from forta_list_grants)"),
301
+ }, async ({ id }) => {
302
+ const res = await api("DELETE", `/admin/grants/${id}`);
303
+ return { content: text(res) };
304
+ });
305
+
306
+ server.tool("forta_check_grant", "Check whether the token's user holds an active grant for a platform, by client_id. Mirrors exactly what a service's grant enforcement sees", {
307
+ client_id: z.string().describe("Platform client_id"),
308
+ }, async ({ client_id }) => {
309
+ const res = await api("GET", "/internal/grants/check", { client_id });
310
+ return { content: text(res) };
311
+ });
312
+
313
+ // --- Token introspection ---
314
+
315
+ server.tool("forta_introspect_token", "Introspect an access token (RFC 7662) — returns active, sub, exp, iat, client_id, token_type. Use this to diagnose whether a token a service rejected is expired, malformed, or bound to a different platform", {
316
+ token: z.string().describe("The access token to introspect"),
317
+ }, async ({ token }) => {
318
+ const res = await api("POST", "/oauth/introspect", null, { token });
319
+ return { content: text(res) };
320
+ });
321
+
322
+ // --- API tokens ---
323
+
324
+ server.tool("forta_list_api_tokens", "List active Forta API tokens with their names, expiry and last_used_at. Token values are never returned", {
325
+ user_id: z.number().optional().describe("Filter to one user's tokens"),
326
+ limit: z.number().optional().describe("Max tokens to return"),
327
+ offset: z.number().optional().describe("Pagination offset"),
328
+ }, async ({ user_id, limit, offset }) => {
329
+ const res = await api("GET", "/admin/api-tokens", { user_id, limit, offset });
330
+ return { content: text(res) };
331
+ });
332
+
333
+ server.tool("forta_create_api_token", "Create a Forta API token owned by the calling user. The plaintext token is returned exactly once and cannot be retrieved again", {
334
+ name: z.string().describe("Human-readable label, e.g. 'ci-pipeline'"),
335
+ expires_in: z.enum(["30d", "90d", "365d", "never"]).optional().describe("Lifetime (default 90d)"),
336
+ scopes: z.string().optional().describe("Optional scope string"),
337
+ }, async ({ name, expires_in, scopes }) => {
338
+ const res = await api("POST", "/admin/api-tokens", null, body({ name, expires_in, scopes }));
339
+ return { content: text(res) };
340
+ });
341
+
342
+ server.tool("forta_revoke_api_token", "Revoke an API token. It stops working within the consuming service's token cache TTL (default 60s). Revoking the token this MCP is using will break this connection", {
343
+ id: z.number().describe("API token ID"),
344
+ }, async ({ id }) => {
345
+ const res = await api("DELETE", `/admin/api-tokens/${id}`);
346
+ return { content: text(res) };
347
+ });
348
+
349
+ const transport = new StdioServerTransport();
350
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "forta-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the Forta OAuth2 identity provider",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "forta-mcp": "index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "forta",
16
+ "auth", "oauth", "identity", "sso",
17
+ "model-context-protocol"
18
+ ],
19
+ "author": "Aiden Appleby",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/aidenappl/forta-mcp.git"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.29.0"
27
+ }
28
+ }