forta-mcp 1.0.1 → 1.2.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 +225 -0
  2. package/README.md +18 -0
  3. package/index.js +49 -2
  4. package/package.json +1 -1
package/AGENTS.md ADDED
@@ -0,0 +1,225 @@
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. `create`/`update` carry **`backchannel_logout_uri`** — see the warning below |
118
+ | Roles | list, get, create, update, delete |
119
+ | Grants | list, create, revoke, check |
120
+ | Tokens | introspect, list/create/revoke API tokens |
121
+
122
+ ### Setting `backchannel_logout_uri` — order matters
123
+
124
+ Forta POSTs a signed `logout_token` to this URL the instant a grant is revoked, so the consumer
125
+ ends the session immediately instead of waiting up to five minutes for its introspection poll.
126
+
127
+ ⚠️ **Only set it once the platform actually serves the endpoint.** Until then every notification
128
+ 404s, is retried six times on a backoff, and is marked `exhausted` — which looks in the logs like
129
+ a broken receiver rather than one that was never built. Deploy first, register second.
130
+
131
+ Pass `null` to clear it. Absent is the normal state for a platform that has not implemented the
132
+ receiver, and it is never an error — such a platform is simply skipped by the sender.
133
+
134
+ `https` only; the emitter refuses anything else at send time and logs
135
+ `FORTA_BACKCHANNEL_BAD_ENDPOINT`.
136
+
137
+ ### Two behaviours that are easy to get wrong
138
+
139
+ **1. `/oauth/introspect` is a resource-server endpoint, not an admin one.** It requires the
140
+ *calling platform's* `client_id` and `client_secret`, reads **form-encoded** parameters, and
141
+ ignores the `Authorization` header entirely. It also only understands access JWTs — an opaque
142
+ `frt_` token reports `active: false`, which reads as "revoked" but means "wrong tool"
143
+ (`forta_list_api_tokens` is the right one). Introspection is scoped to the calling platform, so
144
+ a token valid elsewhere returns `active: false`.
145
+
146
+ The original implementation sent JSON with no credentials and **returned `invalid_client` on
147
+ every call** — it never worked. `opts.form` and `opts.noAuth` exist because of this.
148
+
149
+ **2. `/admin/grants` filters are mutually exclusive.** `HandleAdminListGrants` selects with a
150
+ `switch`, so passing both `platform_id` and `user_id` silently drops `user_id`. The tool
151
+ description says so; do not "fix" it by sending both.
152
+
153
+ ## Ecosystem & related repos
154
+
155
+ | Repo | Relationship |
156
+ |------|--------------|
157
+ | [`forta-api`](https://github.com/aidenappl/forta-api) | The API this wraps. Its `main.go` is the source of truth for coverage. |
158
+ | [`go-forta`](https://github.com/aidenappl/go-forta) | v1.3.0+ accepts the `frt_` tokens this server uses. |
159
+ | [`keyring-mcp`](https://github.com/aidenappl/keyring-mcp) | **Uses the same `frt_` token** — Keyring has no admin credential of its own. |
160
+ | `lattice-mcp`, `monitor-mcp`, `openbucket-mcp` | Sibling MCPs; keep the single-file structure aligned. |
161
+
162
+ ## Operations
163
+
164
+ - **Published to npm** as `forta-mcp` (public). `npx -y` resolves the latest, so **publishing
165
+ is deployment**.
166
+ - **Bump the version in TWO places before publishing** — `package.json` *and* the `McpServer({
167
+ version })` literal in `index.js`. npm refuses to republish an existing version, so forgetting
168
+ `package.json` fails loudly (`cannot publish over the previously published versions`), but
169
+ forgetting the one in `index.js` fails SILENTLY: the server then reports a version it is not,
170
+ and a client debugging "which build am I talking to" is misled by the only field that answers
171
+ that question.
172
+ - **Publishing requires 2FA via passkey** and must run from an interactive terminal — npm's web
173
+ auth flow needs a browser, and from a non-TTY subprocess it degrades to demanding an OTP a
174
+ passkey-only account cannot produce.
175
+ - **A running MCP server does not pick up a new npm version** — the client must restart.
176
+ - **Common failure modes:**
177
+ - *All tools `fetch failed`* — wrong `FORTA_API_URL`. The API is **`auth.appleby.cloud`**;
178
+ `forta.appleby.cloud` is the dashboard and returns 404 on `/healthcheck`.
179
+ - *All tools 401* — token revoked or expired.
180
+ - *`/admin` tools 403 but `forta_get_self` works* — the owning user is not super-admin.
181
+
182
+ ## Rules & guardrails
183
+
184
+ - **Never hardcode a token, URL or hostname.**
185
+ - **Never log request or response bodies.** `forta_create_api_token` and
186
+ `forta_create_platform` return live secrets exactly once.
187
+ - **Never weaken `sanitise()`.** It must stay recursive, applied centrally in `api()`, and on by
188
+ default. If something genuinely needs a real value, the answer is
189
+ `FORTA_ALLOW_SECRET_VALUES=1` in that server's env, not an exemption in the code.
190
+ - **Never add a tool without reading its handler in `forta-api`.** Struct fields are not the
191
+ request contract — that assumption is exactly what shipped the broken introspect tool.
192
+ - **Never rename an existing tool.** Names are a public contract; add and deprecate instead.
193
+ - **`zod` is used but not declared** in `package.json`, resolving transitively through the MCP
194
+ SDK. Declaring it explicitly is the right fix, as a standalone change.
195
+ - Publishing is outward-facing and effectively irreversible (npm unpublish is restricted after
196
+ 72 hours) — do not publish without explicit instruction.
197
+
198
+ ## Verification — always before "done"
199
+
200
+ ```bash
201
+ node --check index.js
202
+ grep -c 'server.tool(' index.js # expect 25
203
+ ```
204
+
205
+ Then the stdio handshake above, asserting the server starts with **no stderr**, the tool count
206
+ is right, and there are **no duplicate names** — `server.tool` accepts duplicates silently, the
207
+ last registration wins, and the earlier tool vanishes without an error.
208
+
209
+ For any tool added or changed, make **one real call against the live API**. Schema checks catch
210
+ typos; they do not catch wrong units, wrong enum values, wrong content types, or parameters the
211
+ handler ignores — every bug this repo has shipped was in that second category.
212
+
213
+ **Never report work complete on the strength of `tools/list` alone.**
214
+
215
+ ## Keeping this file updated
216
+
217
+ Update this AGENTS.md in the same change when you:
218
+ - **Add/remove/rename a tool** → update the group table and the count in the header.
219
+ - **Change `api()` or its `opts`** → update *How code is written here*.
220
+ - **Discover a `forta-api` behaviour that contradicts a tool description** → fix both, and
221
+ record it under *Two behaviours that are easy to get wrong* if it is non-obvious.
222
+ - **Add/change a tool parameter** → bump the minor version in BOTH places (see *Operations*) in
223
+ the same change, so the commit that adds the capability is the commit that can be published.
224
+ - **Publish** → note the version and what changed.
225
+ - 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,6 +62,51 @@ 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
112
  async function api(method, path, params, body, opts_ = {}) {
@@ -91,7 +136,7 @@ async function api(method, path, params, body, opts_ = {}) {
91
136
  const text = await res.text();
92
137
  if (!text) return { success: res.ok, status: res.status };
93
138
  try {
94
- return JSON.parse(text);
139
+ return sanitise(JSON.parse(text));
95
140
  } catch {
96
141
  // /healthcheck and the OAuth endpoints return bare text, not the envelope.
97
142
  return { success: res.ok, status: res.status, body: text };
@@ -113,7 +158,7 @@ function body(obj) {
113
158
 
114
159
  const server = new McpServer({
115
160
  name: "forta",
116
- version: "1.0.0",
161
+ version: "1.2.0",
117
162
  });
118
163
 
119
164
  // --- Health & identity ---
@@ -188,6 +233,7 @@ server.tool("forta_create_platform", "Register a new OAuth platform. Returns the
188
233
  description: z.string().optional().describe("Description"),
189
234
  redirect_uris: z.array(z.string()).optional().describe("Allowed OAuth redirect URIs"),
190
235
  logout_redirect_uris: z.array(z.string()).optional().describe("Allowed post-logout redirect URIs"),
236
+ backchannel_logout_uri: z.string().nullable().optional().describe("OIDC Back-Channel Logout 1.0 endpoint on the platform (https only). Forta POSTs a signed logout_token here the moment a grant is revoked, instead of the consumer waiting up to 5 minutes for its introspection poll. Pass null to clear. ⚠️ Only set this once the platform actually serves the endpoint — until then every notification 404s, retries 6 times and is marked exhausted"),
191
237
  allowed_origins: z.array(z.string()).optional().describe("Allowed CORS origins"),
192
238
  is_first_party: z.boolean().optional().describe("First-party app (skips the consent screen)"),
193
239
  internal_resource: z.boolean().optional().describe("Internal resource, hidden from the app launcher"),
@@ -209,6 +255,7 @@ server.tool("forta_update_platform", "Update a platform's configuration. Only th
209
255
  platform_type: z.enum(["WEB", "SPA", "SERVICE", "MOBILE"]).optional(),
210
256
  redirect_uris: z.array(z.string()).optional(),
211
257
  logout_redirect_uris: z.array(z.string()).optional(),
258
+ backchannel_logout_uri: z.string().nullable().optional().describe("OIDC Back-Channel Logout 1.0 endpoint on the platform (https only). Forta POSTs a signed logout_token here the moment a grant is revoked, instead of the consumer waiting up to 5 minutes for its introspection poll. Pass null to clear. ⚠️ Only set this once the platform actually serves the endpoint — until then every notification 404s, retries 6 times and is marked exhausted"),
212
259
  allowed_origins: z.array(z.string()).optional(),
213
260
  is_first_party: z.boolean().optional(),
214
261
  internal_resource: z.boolean().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forta-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for the Forta OAuth2 identity provider",
5
5
  "type": "module",
6
6
  "main": "index.js",