forta-mcp 1.0.0 → 1.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.
Files changed (4) hide show
  1. package/AGENTS.md +202 -0
  2. package/README.md +18 -0
  3. package/index.js +66 -14
  4. package/package.json +5 -2
package/AGENTS.md ADDED
@@ -0,0 +1,202 @@
1
+ # AGENTS.md — forta-mcp
2
+
3
+ > `forta-mcp` is the **Model Context Protocol server for Forta**, the OAuth2 identity provider
4
+ > for `appleby.cloud`. It exposes the `forta-api` admin surface to Claude Code as **25 typed
5
+ > tools** — users, platforms, roles, grants, token introspection and API tokens. This file
6
+ > orients any agent/worker before touching code in this repo.
7
+ >
8
+ > **⚠️ Golden rule — keep this file current:** any change that adds, removes or retypes a tool,
9
+ > or drifts from `forta-api`'s routes, MUST update this AGENTS.md in the SAME change.
10
+ >
11
+ > **⚠️ Blast radius:** these tools can delete users, delete platforms and rotate client
12
+ > secrets. `forta_rotate_platform_credentials` takes a platform *down* until its deployed config
13
+ > is updated. Tool descriptions are the only warning an agent gets — keep them honest.
14
+
15
+ ---
16
+
17
+ ## What this repo is
18
+
19
+ A single-file Node ESM program (`index.js`) speaking MCP over stdio, translating tool calls into
20
+ HTTP requests against `forta-api` at **`auth.appleby.cloud`**. Published to npm as `forta-mcp`,
21
+ consumed via `npx -y forta-mcp`.
22
+
23
+ It owns only the translation layer — tool names, argument schemas, descriptions, URL
24
+ construction. All behaviour comes from `forta-api`.
25
+
26
+ Its reason to exist: Forta is the identity provider for the whole ecosystem, so nearly every
27
+ auth bug starts there. `forta_list_grants` turns "why is this user getting a 403 from Keyring"
28
+ from a manual database dig into one call.
29
+
30
+ ## Stack & dependencies
31
+
32
+ - **Node ≥18** (global `fetch`, `AbortSignal.timeout`). `"type": "module"`, top-level `await`.
33
+ - **`@modelcontextprotocol/sdk` ^1.29.0**, **`zod`** (transitive via the SDK — not declared).
34
+ - No build step, no tests, no linter. `node --check index.js` is the only static gate.
35
+
36
+ ## Project structure
37
+
38
+ | Path | Role |
39
+ |------|------|
40
+ | `index.js` | `--setup` flow, config guard, `api()`/`text()`/`body()` helpers, all 25 tools, transport. |
41
+ | `package.json` | `bin.forta-mcp` → `index.js`. |
42
+ | `README.md` | User-facing setup + tool tables. |
43
+ | `AGENTS.md` | This file. |
44
+
45
+ ## Running, building & testing
46
+
47
+ ```bash
48
+ npm install
49
+ node --check index.js
50
+ node index.js --setup # writes ~/.mcp.json
51
+ FORTA_API_URL=... FORTA_API_TOKEN=frt_... node index.js # run on stdio
52
+ ```
53
+
54
+ Smoke test without a client (the `sleep`s are required — requests otherwise race the handshake):
55
+
56
+ ```bash
57
+ { echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
58
+ sleep 2
59
+ echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
60
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
61
+ sleep 2; } | FORTA_API_URL=x FORTA_API_TOKEN=frt_x node index.js
62
+ ```
63
+
64
+ ## How code is written here
65
+
66
+ - **Every tool follows one shape** — `server.tool(name, description, zodSchema, handler)`, and
67
+ the handler ends `return { content: text(res) }`.
68
+ - **Naming:** `forta_` prefix, then verb, then noun.
69
+ - **`api(method, path, params, body, opts)`** — `params` become query entries (undefined/null
70
+ dropped); `body` is JSON by default. `opts.form` switches to
71
+ `application/x-www-form-urlencoded`; `opts.noAuth` omits the `Authorization` header. **Both
72
+ exist solely for `/oauth/introspect`** — see below.
73
+ - **`body(obj)` strips `undefined`.** Always use it on PUT tools: `forta-api` treats a
74
+ present-but-null field as an explicit clear, so forwarding raw `{...fields}` would wipe every
75
+ field the caller omitted.
76
+ - **Descriptions carry the blast radius.** Anything destructive must say so and name the safer
77
+ alternative.
78
+
79
+ ### Sensitive value masking
80
+
81
+ `api()` passes every decoded JSON response through **`sanitise()`** before returning it. This is
82
+ central, not per-tool, so a newly added tool is safe by default rather than by remembering.
83
+
84
+ `mask()` keeps a value's **first two characters** and appends a **fixed-width tail** —
85
+ `"supersecret"` → `"su**********"`. The prefix is what makes the mask useful rather than merely
86
+ safe: you can still tell one credential from another, or confirm a rotation actually changed a
87
+ value. The tail is fixed width so the mask does not disclose the real length. Values under
88
+ three characters are masked whole, so a two-character secret cannot round-trip as itself.
89
+
90
+ The same shape is implemented in all five `*-mcp` servers.
91
+
92
+ `SECRET_FIELDS` covers `client_secret`, `secret`, `password`, `token`, `api_token`,
93
+ `access_token`, `refresh_token`, `id_token`, `oauth_request_token`, `plaintext`, `private_key`
94
+ and `signing_key`, masked at **any nesting depth**, in objects and arrays alike.
95
+
96
+ This matters most on the three tools that mint a credential and return it exactly once:
97
+ `forta_create_platform` and `forta_rotate_platform_credentials` (a `client_secret`) and
98
+ `forta_create_api_token` (a plaintext `frt_` token). Those are keys to the whole SSO estate.
99
+ Introspection metadata — `active`, `sub`, `exp`, `token_type` — is deliberately left readable,
100
+ since that is the entire point of `forta_introspect_token`.
101
+
102
+ `FORTA_ALLOW_SECRET_VALUES=1` disables masking. It is off by default and should stay that way;
103
+ read a real credential from the Forta dashboard instead.
104
+
105
+ ## Domain & architecture
106
+
107
+ **Auth.** One long-lived `frt_` API token in `FORTA_API_TOKEN`, sent as
108
+ `Authorization: Bearer` on every request. `/admin/*` additionally requires the owning user to
109
+ be a Forta **super-admin** — the token does not bypass that.
110
+
111
+ **Tool groups:**
112
+
113
+ | Group | Tools |
114
+ |-------|-------|
115
+ | Health & identity | `forta_health`, `forta_get_self` |
116
+ | Users | list, get, update, delete |
117
+ | Platforms | list, get, create, update, delete, rotate credentials |
118
+ | Roles | list, get, create, update, delete |
119
+ | Grants | list, create, revoke, check |
120
+ | Tokens | introspect, list/create/revoke API tokens |
121
+
122
+ ### Two behaviours that are easy to get wrong
123
+
124
+ **1. `/oauth/introspect` is a resource-server endpoint, not an admin one.** It requires the
125
+ *calling platform's* `client_id` and `client_secret`, reads **form-encoded** parameters, and
126
+ ignores the `Authorization` header entirely. It also only understands access JWTs — an opaque
127
+ `frt_` token reports `active: false`, which reads as "revoked" but means "wrong tool"
128
+ (`forta_list_api_tokens` is the right one). Introspection is scoped to the calling platform, so
129
+ a token valid elsewhere returns `active: false`.
130
+
131
+ The original implementation sent JSON with no credentials and **returned `invalid_client` on
132
+ every call** — it never worked. `opts.form` and `opts.noAuth` exist because of this.
133
+
134
+ **2. `/admin/grants` filters are mutually exclusive.** `HandleAdminListGrants` selects with a
135
+ `switch`, so passing both `platform_id` and `user_id` silently drops `user_id`. The tool
136
+ description says so; do not "fix" it by sending both.
137
+
138
+ ## Ecosystem & related repos
139
+
140
+ | Repo | Relationship |
141
+ |------|--------------|
142
+ | [`forta-api`](https://github.com/aidenappl/forta-api) | The API this wraps. Its `main.go` is the source of truth for coverage. |
143
+ | [`go-forta`](https://github.com/aidenappl/go-forta) | v1.3.0+ accepts the `frt_` tokens this server uses. |
144
+ | [`keyring-mcp`](https://github.com/aidenappl/keyring-mcp) | **Uses the same `frt_` token** — Keyring has no admin credential of its own. |
145
+ | `lattice-mcp`, `monitor-mcp`, `openbucket-mcp` | Sibling MCPs; keep the single-file structure aligned. |
146
+
147
+ ## Operations
148
+
149
+ - **Published to npm** as `forta-mcp` (public). `npx -y` resolves the latest, so **publishing
150
+ is deployment**.
151
+ - **Publishing requires 2FA via passkey** and must run from an interactive terminal — npm's web
152
+ auth flow needs a browser, and from a non-TTY subprocess it degrades to demanding an OTP a
153
+ passkey-only account cannot produce.
154
+ - **A running MCP server does not pick up a new npm version** — the client must restart.
155
+ - **Common failure modes:**
156
+ - *All tools `fetch failed`* — wrong `FORTA_API_URL`. The API is **`auth.appleby.cloud`**;
157
+ `forta.appleby.cloud` is the dashboard and returns 404 on `/healthcheck`.
158
+ - *All tools 401* — token revoked or expired.
159
+ - *`/admin` tools 403 but `forta_get_self` works* — the owning user is not super-admin.
160
+
161
+ ## Rules & guardrails
162
+
163
+ - **Never hardcode a token, URL or hostname.**
164
+ - **Never log request or response bodies.** `forta_create_api_token` and
165
+ `forta_create_platform` return live secrets exactly once.
166
+ - **Never weaken `sanitise()`.** It must stay recursive, applied centrally in `api()`, and on by
167
+ default. If something genuinely needs a real value, the answer is
168
+ `FORTA_ALLOW_SECRET_VALUES=1` in that server's env, not an exemption in the code.
169
+ - **Never add a tool without reading its handler in `forta-api`.** Struct fields are not the
170
+ request contract — that assumption is exactly what shipped the broken introspect tool.
171
+ - **Never rename an existing tool.** Names are a public contract; add and deprecate instead.
172
+ - **`zod` is used but not declared** in `package.json`, resolving transitively through the MCP
173
+ SDK. Declaring it explicitly is the right fix, as a standalone change.
174
+ - Publishing is outward-facing and effectively irreversible (npm unpublish is restricted after
175
+ 72 hours) — do not publish without explicit instruction.
176
+
177
+ ## Verification — always before "done"
178
+
179
+ ```bash
180
+ node --check index.js
181
+ grep -c 'server.tool(' index.js # expect 25
182
+ ```
183
+
184
+ Then the stdio handshake above, asserting the server starts with **no stderr**, the tool count
185
+ is right, and there are **no duplicate names** — `server.tool` accepts duplicates silently, the
186
+ last registration wins, and the earlier tool vanishes without an error.
187
+
188
+ For any tool added or changed, make **one real call against the live API**. Schema checks catch
189
+ typos; they do not catch wrong units, wrong enum values, wrong content types, or parameters the
190
+ handler ignores — every bug this repo has shipped was in that second category.
191
+
192
+ **Never report work complete on the strength of `tools/list` alone.**
193
+
194
+ ## Keeping this file updated
195
+
196
+ Update this AGENTS.md in the same change when you:
197
+ - **Add/remove/rename a tool** → update the group table and the count in the header.
198
+ - **Change `api()` or its `opts`** → update *How code is written here*.
199
+ - **Discover a `forta-api` behaviour that contradicts a tool description** → fix both, and
200
+ record it under *Two behaviours that are easy to get wrong* if it is non-obvious.
201
+ - **Publish** → note the version and what changed.
202
+ - Also keep `README.md`'s tool tables in sync.
package/README.md CHANGED
@@ -46,6 +46,24 @@ curl -X POST https://auth.appleby.cloud/admin/api-tokens \
46
46
 
47
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
48
 
49
+ ## Secret values are masked
50
+
51
+ Every response is passed through a masking step before it reaches the model. Anything that looks
52
+ like a credential keeps its **first two characters** and loses the rest to a fixed-width tail —
53
+ `supersecret` becomes `su**********`.
54
+
55
+ That is enough to tell two credentials apart, or to confirm a rotation actually changed
56
+ something, and not enough to use. The tail is a fixed width so the mask does not reveal the real
57
+ length.
58
+
59
+ This matters most on the tools that mint a credential and return it exactly once —
60
+ `forta_create_platform` and `forta_rotate_platform_credentials` (a `client_secret`) and
61
+ `forta_create_api_token` (a plaintext `frt_` token). Read the real value from the Forta
62
+ dashboard instead. Introspection metadata (`active`, `sub`, `exp`, `token_type`) is left
63
+ readable.
64
+
65
+ Set `FORTA_ALLOW_SECRET_VALUES=1` to turn masking off if you genuinely need a working value.
66
+
49
67
  ## Tools
50
68
 
51
69
  ### Health & identity
package/index.js CHANGED
@@ -62,9 +62,54 @@ if (!API_URL || !API_TOKEN) {
62
62
  process.exit(1);
63
63
  }
64
64
 
65
+ // --- Sensitive value masking ---
66
+ //
67
+ // Several Forta endpoints return a live credential exactly once, at the moment
68
+ // it is minted: forta_create_platform and forta_rotate_platform_credentials
69
+ // return a client_secret, forta_create_api_token returns a plaintext frt_
70
+ // token. Those are the keys to the whole SSO estate, and anything that reaches
71
+ // a model's context is in a transcript forever.
72
+ //
73
+ // Sensitive values are masked to their first two characters plus a
74
+ // fixed-width tail: "frt_abc123" -> "fr**********". The prefix keeps a value
75
+ // identifiable and comparable while the fixed tail avoids disclosing length.
76
+ // Read the usable value from the Forta dashboard instead.
77
+ //
78
+ // Set FORTA_ALLOW_SECRET_VALUES=1 to pass values through unmasked.
79
+
80
+ const ALLOW_SECRETS = process.env.FORTA_ALLOW_SECRET_VALUES === "1";
81
+
82
+ function mask(value) {
83
+ if (typeof value !== "string" || value === "") return value;
84
+ // Under three characters there is no prefix worth keeping — a two-char
85
+ // secret would otherwise round-trip as itself.
86
+ if (value.length < 3) return "**********";
87
+ return value.slice(0, 2) + "**********";
88
+ }
89
+
90
+ // Response fields that are a credential wherever they appear. `id_token` and
91
+ // `access_token` are included because the OAuth endpoints return real, usable
92
+ // session tokens.
93
+ const SECRET_FIELDS = new Set([
94
+ "client_secret", "secret", "password", "token", "api_token", "access_token",
95
+ "refresh_token", "id_token", "oauth_request_token", "plaintext",
96
+ "private_key", "signing_key",
97
+ ]);
98
+
99
+ // Walks a decoded response and masks every sensitive value in place.
100
+ function sanitise(node) {
101
+ if (ALLOW_SECRETS || node === null || typeof node !== "object") return node;
102
+ if (Array.isArray(node)) return node.map(sanitise);
103
+ const out = {};
104
+ for (const [k, v] of Object.entries(node)) {
105
+ out[k] = SECRET_FIELDS.has(k) && typeof v === "string" ? mask(v) : sanitise(v);
106
+ }
107
+ return out;
108
+ }
109
+
65
110
  // --- HTTP helper ---
66
111
 
67
- async function api(method, path, params, body) {
112
+ async function api(method, path, params, body, opts_ = {}) {
68
113
  const url = new URL(path, API_URL);
69
114
  if (params) {
70
115
  for (const [k, v] of Object.entries(params)) {
@@ -73,12 +118,16 @@ async function api(method, path, params, body) {
73
118
  }
74
119
  const opts = {
75
120
  method,
76
- headers: {
77
- Authorization: `Bearer ${API_TOKEN}`,
78
- },
121
+ headers: {},
79
122
  signal: AbortSignal.timeout(30000),
80
123
  };
81
- if (body) {
124
+ // The OAuth endpoints authenticate with client credentials, not the API
125
+ // token, and reject a stray Authorization header.
126
+ if (!opts_.noAuth) opts.headers.Authorization = `Bearer ${API_TOKEN}`;
127
+ if (body && opts_.form) {
128
+ opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
129
+ opts.body = new URLSearchParams(body).toString();
130
+ } else if (body) {
82
131
  opts.headers["Content-Type"] = "application/json";
83
132
  opts.body = JSON.stringify(body);
84
133
  }
@@ -87,7 +136,7 @@ async function api(method, path, params, body) {
87
136
  const text = await res.text();
88
137
  if (!text) return { success: res.ok, status: res.status };
89
138
  try {
90
- return JSON.parse(text);
139
+ return sanitise(JSON.parse(text));
91
140
  } catch {
92
141
  // /healthcheck and the OAuth endpoints return bare text, not the envelope.
93
142
  return { success: res.ok, status: res.status, body: text };
@@ -109,7 +158,7 @@ function body(obj) {
109
158
 
110
159
  const server = new McpServer({
111
160
  name: "forta",
112
- version: "1.0.0",
161
+ version: "1.1.0",
113
162
  });
114
163
 
115
164
  // --- Health & identity ---
@@ -279,9 +328,9 @@ server.tool("forta_delete_role", "Delete a role. Users holding it lose its permi
279
328
 
280
329
  // --- Grants ---
281
330
 
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"),
331
+ server.tool("forta_list_grants", "List access grants — which users can access which platforms, and with what role. The first place to look when a user gets a 403 from an appleby.cloud service. Filters are mutually exclusive, not combined: pass platform_id OR user_id, or neither to list all. Passing both silently ignores user_id", {
332
+ platform_id: z.number().optional().describe("Filter to one platform (takes precedence over user_id)"),
333
+ user_id: z.number().optional().describe("Filter to one user (ignored if platform_id is also given)"),
285
334
  }, async ({ platform_id, user_id }) => {
286
335
  const res = await api("GET", "/admin/grants", { platform_id, user_id });
287
336
  return { content: text(res) };
@@ -312,10 +361,13 @@ server.tool("forta_check_grant", "Check whether the token's user holds an active
312
361
 
313
362
  // --- Token introspection ---
314
363
 
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 });
364
+ server.tool("forta_introspect_token", "Introspect an access JWT (RFC 7662) — returns active, sub, exp, iat, token_type. Answers 'is the token this service rejected expired, malformed, or lacking a grant?'. Requires the calling platform's client credentials: introspection is scoped to that platform, and a token valid elsewhere returns active=false here. Only works for access JWTs — opaque frt_ API tokens always report active=false, so use forta_list_api_tokens to inspect those instead", {
365
+ token: z.string().describe("The access JWT to introspect"),
366
+ client_id: z.string().describe("Calling platform's client_id (from forta_list_platforms)"),
367
+ client_secret: z.string().describe("Calling platform's client_secret — shown only when the platform is created or its credentials are rotated"),
368
+ }, async ({ token, client_id, client_secret }) => {
369
+ const res = await api("POST", "/oauth/introspect", null,
370
+ { token, client_id, client_secret }, { form: true, noAuth: true });
319
371
  return { content: text(res) };
320
372
  });
321
373
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forta-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for the Forta OAuth2 identity provider",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -13,7 +13,10 @@
13
13
  "keywords": [
14
14
  "mcp",
15
15
  "forta",
16
- "auth", "oauth", "identity", "sso",
16
+ "auth",
17
+ "oauth",
18
+ "identity",
19
+ "sso",
17
20
  "model-context-protocol"
18
21
  ],
19
22
  "author": "Aiden Appleby",