Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Partner mode: one partner API key for every managed tenant.** MSPs no longer need a Reporting API key per customer. Set `KNOWBE4_PARTNER_API_KEY` (env mode) or send `X-KnowBe4-Partner-API-Key` (gateway mode) and the server talks to KnowBe4's partner GraphQL API (`https://training.knowbe4.com/graphql` and the eu/ca/uk/de equivalents, selected by `KNOWBE4_REGION`; `KNOWBE4_GRAPHQL_URL` overrides).
- New `partner` domain: `knowbe4_partner_accounts_list` (search, status, pagination; returns each managed account's risk score, phish-prone percentage, percent trained, seats, subscription end date) and `knowbe4_partner_account_get`.
- Every tenant tool gains an optional `account_id`. When set, the call is served over the tenant GraphQL API using a Just-In-Time token minted with `apiTokensGenerateJit(accountId)`, cached for 14 minutes (KnowBe4 documents a 15-minute lifetime) and keyed by a hash of the partner key plus the account id so partners sharing one gateway never share tokens. Concurrent calls for one tenant share a single mint; an authentication failure evicts the cached token. JWTs are never logged. Without `account_id` the REST Reporting API path is unchanged. `account_id` deliberately has no REST fallback: with partner mode unconfigured the tool errors rather than answering from the wrong tenant.
- GraphQL equivalents of all 27 tenant tools live in `src/graphql/<domain>.ts` with the same tool names, argument names, and validation messages; results use KnowBe4's camelCase GraphQL field names. Selections stay small to respect KnowBe4's 150-line complexity cap. Page sizes are clamped to KnowBe4's 25..1000 window.
- `knowbe4_status` reports both tenant (REST) and partner (GraphQL) credentials. Gateway mode now accepts a tenant key, a partner key, or both, and returns 401 only when neither is present.
- Feature parity with the REST path: partner-mode `knowbe4_users_get` attaches the MCP Apps `_card` (the card builder now normalizes both REST snake_case and GraphQL camelCase users, deriving status from `archived` and the trend from `riskScoreHistories`), and partner-mode `knowbe4_users_list` offers the same filter prompt when called without filters. Write mutations remain out of scope. Design notes in `docs/superpowers/specs/2026-09-09-partner-mode-design.md`. Not verified against a live partner tenant (no partner key available); the GraphQL documents were validated against KnowBe4's published schema via introspection.
- **Interactive user card via MCP Apps (SEP-1865).** `knowbe4_users_get` results now render as an interactive card in MCP Apps hosts (Claude Desktop/web, and other hosts advertising the `io.modelcontextprotocol/ui` extension), instead of a wall of JSON. The card shows the user's name, email, status, job title, department, manager, location, and group count, plus their current risk score and phish-prone percentage with a best-effort risk-score trend fetched from `/api/v1/users/{id}/risk_score_history`. The card is read-only — KnowBe4 is a reporting surface here, so there is no write round-trip. Non-App hosts are unaffected: the tool's JSON payload is unchanged apart from a new `_card` field.
- The renderable tool advertises the UI via `_meta` (`ui/resourceUri`, plus the nested `ui.resourceUri` form) pointing at a new `ui://knowbe4/user-card.html` resource served as `text/html;profile=mcp-app`. The card HTML is a self-contained vite single-file bundle embedded at build time (`src/generated/user-card-html.ts`, committed), so plain `npm run build` and CI don't need vite. The server now declares the `resources` capability and answers `resources/list` / `resources/read` (`src/resources.ts`).
- The card is neutral by default (system fonts, no vendor identity, no external fetches) and brandable via `window.__BRAND__` injection or `MCP_BRAND_*` env vars (`MCP_BRAND_NAME`, `MCP_BRAND_LOGO_URL`, `MCP_BRAND_PRIMARY_COLOR`, `MCP_BRAND_ACCENT_COLOR`, `MCP_BRAND_BG`, `MCP_BRAND_TEXT`): at serve time the server replaces the card's BRAND_INJECT marker with an inline, `<`-escaped `window.__BRAND__` script, so self-hosters can theme the card without rebuilding. No brand configured = HTML served unchanged.
- The card payload builder is best-effort: a failed risk-history fetch degrades the card (or drops it) without affecting the tool result. New contract tests in `src/__tests__/mcp-apps.test.ts` pin the `_meta` advertisement, the `ui://` resource wire shape, the neutral-default/brand-injection behavior, and the card normalization.

### Fixed
- `knowbe4_store_purchases_list`, `knowbe4_store_purchases_get`, `knowbe4_policies_list`, and `knowbe4_policies_get` were unreachable in the default (non-lazy) mode: the dispatcher routed by six hard-coded name prefixes (`knowbe4_training_` and friends) and these tools, which live in the training domain, matched none of them, so every call returned `Unknown tool`. Routing now uses the tool category map (`findDomainForTool`), the same source of truth lazy-loading mode already used.
- Every tool call returned `HTTP 404` against the live API. The KnowBe4 Reporting API serves endpoints under `/v1` (e.g. `https://eu.api.knowbe4.com/v1/account`), but every `apiRequest()` call site across `src/domains/{account,groups,phishing,reporting,training,users}.ts` was hardcoded to `/api/v1/...`. Verified against a live EU-region tenant: `/v1/account` returns `200`, `/api/v1/account` returns `404`. All call sites and their test expectations were corrected to `/v1/...`.
- A `KNOWBE4_BASE_URL` containing a path prefix is no longer silently discarded. `apiRequest()` built its request URL with `new URL(path, creds.baseUrl)`, and the two-argument `URL` constructor performs RFC 3986 relative resolution rather than string concatenation. Every call site passes a path-absolute reference (`/api/v1/account`, `/api/v1/phishing/security_tests`, …), which by that rule replaces the base URL's own path entirely — so a base of `https://proxy.corp.example/knowbe4/` produced `https://proxy.corp.example/api/v1/account` and every request went to the wrong place. The bug was invisible by default because the built-in region defaults (`https://us.api.knowbe4.com` and friends) are bare origins with no path to lose; it only bit self-hosters who pointed `KNOWBE4_BASE_URL` at a reverse proxy or API gateway mounted under a path, exactly the use case the README documents as "Custom base URL (overrides region)". The URL is now assembled by explicit slash normalization and concatenation, so the base's path survives regardless of leading/trailing slashes on either side, redundant slashes collapse to one, and query params still append after the joined path. Regression tests in `src/__tests__/client.test.ts` pin the exact URL handed to `fetch` for bare-origin, prefixed-with-trailing-slash, prefixed-without-trailing-slash, doubled-slash, and query-param cases.
- HTTP transport now builds a fresh `Server` + `StreamableHTTPServerTransport` per `/mcp` request (stateless mode, `sessionIdGenerator: undefined`) instead of sharing one stateful transport for the whole process. The shared stateful transport (created with `sessionIdGenerator: () => randomUUID()`) only accepted one `initialize`, so behind the multi-user gateway only the first client since container start received tools — every subsequent client got `-32600 "Server already initialized"` and saw zero tools until a restart. Each request is now independent, so multiple clients work simultaneously. Handler registration was extracted into a `createFreshServer()` factory; stdio mode keeps its single shared server. Per-request server/transport are disposed on response close, and non-`POST` `/mcp` requests now return `405`.
Expand Down
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,44 @@ Set the following environment variables:

| Variable | Required | Description |
|----------|----------|-------------|
| `KNOWBE4_API_KEY` | Yes | Your KnowBe4 API key |
| `KNOWBE4_REGION` | No | API region: us, eu, ca, uk, de (default: us) |
| `KNOWBE4_BASE_URL` | No | Custom base URL (overrides region) |
| `KNOWBE4_API_KEY` | One of the two keys | Your KnowBe4 Reporting API key for a single tenant |
| `KNOWBE4_PARTNER_API_KEY` | One of the two keys | Your KnowBe4 partner Product API key (see [Partner mode](#partner-mode-one-key-for-every-managed-tenant)) |
| `KNOWBE4_REGION` | No | API region: us, eu, ca, uk, de (default: us). Selects both the REST and GraphQL endpoints |
| `KNOWBE4_BASE_URL` | No | Custom REST base URL (overrides region) |
| `KNOWBE4_GRAPHQL_URL` | No | Custom GraphQL endpoint (overrides region) |
| `MCP_TRANSPORT` | No | Transport mode: stdio (default) or http |

## Partner mode (one key for every managed tenant)

MSPs and multi-account admins normally need one Reporting API key per customer
tenant. Partner mode replaces that with a single partner Product API key from
the KnowBe4 partner (management) console:

1. Set `KNOWBE4_PARTNER_API_KEY` (or send the `X-KnowBe4-Partner-API-Key`
header in gateway mode). `KNOWBE4_API_KEY` becomes optional.
2. Call `knowbe4_partner_accounts_list` to see every managed account with its
risk score, phish-prone percentage, percent trained, seats, and
subscription end date. Filter with `search` by company name or domain.
3. Pass the account's `id` as `account_id` to any tenant tool, for example
`knowbe4_users_list` with `account_id: 12345`. The server mints a
just-in-time (JIT) token for that tenant through the partner API, caches it
for 14 minutes, and runs the query against KnowBe4's tenant GraphQL API.

Without `account_id`, tenant tools keep using the REST Reporting API and
`KNOWBE4_API_KEY` exactly as before. `account_id` never falls back to the
single-tenant key: if partner mode is not configured the tool returns an error
instead of silently answering from the wrong tenant.

Notes:

- The partner API requires a Diamond-level partner subscription; the tenant
GraphQL API requires Diamond or SAT Advanced on the managed account.
- KnowBe4 limits GraphQL to 4 requests/second and 10 requests per licensed
user per day, with a 150-line complexity cap per query.
- Partner-mode results use KnowBe4's GraphQL field names (camelCase), so
they differ in shape from the REST results of the same tool. The
interactive user card and the user-list filter prompt work in both modes.

## Usage

### Running with Claude Desktop
Expand Down Expand Up @@ -83,6 +116,10 @@ Training campaign management
### Users
User management and risk scoring

### Partner
Managed accounts (customer tenants) and fleet-wide risk metrics. Requires
`KNOWBE4_PARTNER_API_KEY`; see [Partner mode](#partner-mode-one-key-for-every-managed-tenant).

## Interactive User Card (MCP Apps)

`knowbe4_users_get` renders as an interactive card in MCP Apps hosts
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ services:
- MCP_TRANSPORT=http
- MCP_HTTP_PORT=8080
- AUTH_MODE=${AUTH_MODE:-env}
- KNOWBE4_API_KEY=${KNOWBE4_API_KEY}
- KNOWBE4_API_KEY=${KNOWBE4_API_KEY:-}
- KNOWBE4_PARTNER_API_KEY=${KNOWBE4_PARTNER_API_KEY:-}
- KNOWBE4_REGION=${KNOWBE4_REGION:-us}
- LOG_LEVEL=${LOG_LEVEL:-info}
healthcheck:
Expand Down
107 changes: 107 additions & 0 deletions docs/superpowers/specs/2026-09-09-partner-mode-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Partner mode: one partner key for every managed tenant

**Date:** 2026-09-09
**Status:** approved (user: "let's get this built")

## Problem

MSPs hold one KnowBe4 tenant per customer. The server authenticates with a
single tenant Reporting API key, so serving N customers means N keys and N
server instances (or N gateway connectors). KnowBe4's answer is the
partner-level GraphQL API: one partner Product API key lists every managed
account and mints a 15-minute Just-In-Time (JIT) token per tenant.

## Verified facts (introspection + docs, 2026-09-09)

- Partner and tenant GraphQL share a regional base URL:
`https://training.knowbe4.com/graphql` (US), `eu.`, `ca.`, `uk.`, `de.knowbe4.com/graphql`.
Auth is `Authorization: Bearer <Product API key>`.
- Partner query `accounts(per, page, cursor, status, search, ...)` returns
managed accounts with `id`, `companyName`, `domain`, `riskScore`,
`phishPronePercentage`, `percentageUsersTrained`, `numberOfAllSeats`,
`subscriptionEndDate`, `pstCount`, `trainingCampaignCount`, `hasApi`.
`per` minimum is 25.
- Partner mutation `apiTokensGenerateJit(accountId: Int!)` returns
`{ node: String (JWT), errors: [Error] }`. Token valid 15 minutes, documented
for the tenant GraphQL API ("account-level queries and mutations").
- Tenant GraphQL API: 98 queries, 172 mutations. Every existing REST tool has
a direct query equivalent.
- Limits: 150-line complexity per query, 4 req/s, 10 req per licensed user/day.
Partner API needs Diamond; tenant GraphQL needs Diamond or SAT Advanced.
- Not verified (no partner key available): whether a JIT JWT works against the
legacy REST Reporting API. Design assumes it does not.

## Design

Additive. The existing single-tenant REST path is untouched.

### Credentials

| Mode | Tenant (existing) | Partner (new) |
|------|-------------------|---------------|
| env | `KNOWBE4_API_KEY` | `KNOWBE4_PARTNER_API_KEY` |
| gateway | `X-KnowBe4-API-Key` | `X-KnowBe4-Partner-API-Key` |

`KNOWBE4_REGION` / `X-KnowBe4-Region` select both the REST base and the
GraphQL base. `KNOWBE4_GRAPHQL_URL` overrides the GraphQL base (mirrors
`KNOWBE4_BASE_URL`). Gateway mode accepts either key; at least one is required.

`KnowBe4Credentials` gains optional `partnerApiKey` and `graphqlUrl` so the
per-request `AsyncLocalStorage` store carries both. `getPartnerCredentials()`
in `src/utils/graphql.ts` reads the store first, then env.

### Components

- `src/utils/graphql.ts`: `graphqlRequest(url, token, query, variables)`.
POST JSON, Bearer auth, throws on HTTP error or non-empty `errors`.
- `src/utils/jit.ts`: `getJitToken(accountId)` with an in-memory cache keyed by
`sha256(partnerKey)[:16] + ":" + accountId`, TTL 14 minutes, in-flight
de-duplication. `tenantQuery(accountId, query, variables)` mints (or reuses)
the JIT token and runs the query. JWTs are never logged.
- `src/domains/partner.ts`: new `partner` domain with
`knowbe4_partner_accounts_list(search?, status?, page?, per_page?)` and
`knowbe4_partner_account_get(account_id)`. Uses the partner key directly.
- `src/graphql/{account,users,groups,phishing,training,reporting}.ts`:
tenant tools re-implemented over GraphQL. Each exports
`handle(toolName, accountId, args): Promise<CallToolResult>` and reuses the
existing tool names and argument names.
- `src/graphql/index.ts`: `isPartnerScoped(args)`, `withAccountIdArg(tools)`
(adds an optional `account_id` property to every tenant tool schema), and
`callViaPartner(name, args)` which routes by tool-name prefix.
- `src/index.ts`: tools list = augmented tenant tools + partner tools. Dispatch:
if `account_id` is present, route to `callViaPartner`; otherwise existing REST
routing. Same hook in the lazy-mode `knowbe4_execute_tool`. Status tool
reports both credentials. Gateway header parsing accepts the partner header.

### Data flow (partner-scoped call)

1. Claude calls `knowbe4_partner_accounts_list(search: "acme")` → account id.
2. Claude calls `knowbe4_users_list(account_id: 123, status: "active")`.
3. Dispatcher sees `account_id` → `callViaPartner` → `src/graphql/users.ts`.
4. `tenantQuery(123, USERS_QUERY, vars)` → cache miss → `apiTokensGenerateJit`
with the partner key → JWT cached → `users(...)` query with the JWT.
5. Raw GraphQL `data` is returned as JSON, plus `account_id`.

### Error handling

- `account_id` without partner credentials → tool error naming the env var and
header. No REST fallback (it would silently query the wrong tenant).
- JIT mutation `errors` non-empty or `node` null → tool error with reasons.
- GraphQL `errors` → tool error listing messages; HTTP 401/403/429 mapped like
the REST client.
- Partner tools without partner credentials → tool error.

### Out of scope

- The MCP Apps user card in partner mode (REST field shapes only).
- Elicitation prompts in partner mode.
- Write mutations. Cross-tenant fan-out helpers beyond `accounts` metrics.
- Migrating the REST path to GraphQL (candidate for 2.0).

### Testing

Vitest, mocked `fetch` / mocked `tenantQuery`. Pins: GraphQL request shape,
JIT cache TTL/keying/de-dup/no-log, partner tools' query documents and
variable mapping, per-domain query documents and variable mapping, dispatcher
routing, tool list augmentation, gateway header handling. No live tests
(no partner key on hand).
12 changes: 10 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"args": ["${__dirname}/dist/index.js"],
"env": {
"KNOWBE4_API_KEY": "${user_config.api_key}",
"KNOWBE4_PARTNER_API_KEY": "${user_config.partner_api_key}",
"KNOWBE4_REGION": "${user_config.region}"
}
}
Expand All @@ -33,8 +34,15 @@
"api_key": {
"type": "string",
"title": "API Key",
"description": "Your KnowBe4 Reporting API key (from Account Settings > API)",
"required": true,
"description": "Your KnowBe4 Reporting API key for a single tenant (from Account Settings > API). Required unless a Partner API Key is set.",
"required": false,
"sensitive": true
},
"partner_api_key": {
"type": "string",
"title": "Partner API Key",
"description": "Partner mode: your KnowBe4 partner Product API key (from the partner console). Lets tenant tools take an account_id for any managed tenant.",
"required": false,
"sensitive": true
},
"region": {
Expand Down
Loading
Loading