forta-mcp 1.0.1 → 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.
- package/AGENTS.md +202 -0
- package/README.md +18 -0
- package/index.js +47 -2
- package/package.json +1 -1
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,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.
|
|
161
|
+
version: "1.1.0",
|
|
117
162
|
});
|
|
118
163
|
|
|
119
164
|
// --- Health & identity ---
|