A for Keel. Point any MCP client (Claude Desktop, Claude Code, Cursor) at it and drive your compliance program in natural language: "what's my ISO 27001 readiness?
Overview
A for Keel. Point any MCP client (Claude Desktop, Claude Code, Cursor) at it and drive your compliance program in natural language: "what's my ISO 27001 readiness?", "list controls still in gap", "open a task to rotate our TLS certs." Docs: https://docs.keelgrc.com/api-mcp/mcp-server/ · Open source: https://keelgrc.com/open-source/ It's a thin wrapper over the Keel public API (/api/v1). Every action maps to a real endpoint and is scoped to your API key's organization. The MCP grants no more access than the key already has. One tool per resource, with an action argument. Thirteen tools cover forty-five operations; the alternative is a tool per operation, and MCP clients degrade badly past roughly a hundred tools. Some actions are absent. Each is a gap in the REST surface, and each is stated in the tool description so a model reads an answer rather than a hole: - keel_tasks has no . Keel has no delete-a-task operation anywhere, the app included.
README
keelgrc-mcp
A Model Context Protocol (MCP) server for Keel. Point any MCP client (Claude Desktop, Claude Code, Cursor) at it and drive your compliance program in natural language: “what’s my ISO 27001 readiness?”, “list controls still in gap”, “open a task to rotate our TLS certs.”
Docs: · Open source:
KEEL_API_KEY=your_key npx keelgrc-mcp
It’s a thin wrapper over the Keel public API (/api/v1). Every action maps to a real
endpoint and is scoped to your API key’s organization. The MCP grants no more access
than the key already has.
Tools
One tool per resource, with an action argument. Thirteen tools cover forty-five
operations; the alternative is a tool per operation, and MCP clients degrade badly
past roughly a hundred tools.
| Tool | Actions | Maps to |
|---|---|---|
keel_whoami |
(none) | GET /me |
keel_members |
(none) | GET /members |
keel_frameworks |
(none) | GET /frameworks |
keel_readiness |
(none) | GET /readiness |
keel_controls |
list, get, create, update, delete, mappings, map, unmap | /controls, /controls/{id}, /controls/{id}/crosswalks |
keel_starter_controls |
(none) | POST /frameworks/{key}/starter-controls |
keel_tasks |
list, get, create, update | /tasks, /tasks/{id} |
keel_risks |
list, get, create, update, delete | /risks, /risks/{id} |
keel_vendors |
list, get, create, update, delete | /vendors, /vendors/{id} |
keel_people |
list, get, create, update, delete | /people, /people/{id} |
keel_policies |
list, get, create, update, delete | /policies, /policies/{id} |
keel_evidence |
list, get, create, update, delete | /evidence, /evidence/{id} |
keel_webhooks |
list, create, rotate, delete | /hooks, /hooks/{id}, /hooks/{id}/rotate |
Some actions are absent. Each is a gap in the REST surface, and each is stated in the tool description so a model reads an answer rather than a hole:
keel_taskshas no delete. Keel has no delete-a-task operation anywhere, the app included. Update the status tocancelledinstead, which keeps the record.keel_controlscreatetakes no state, owner or mappings. It takes a name, a description and an optionalkeyof your own, which is all the app’s “Add a control” form takes. A new control startsnot_started, unowned and unmapped: setstateandownerEmailwithupdate, and clauses withmap, which attaches any control to any clause of an applied framework.- Applying a framework is not here. It is plan-metered and happens in the app;
keel_starter_controlsandmaprefuse a framework the workspace has not applied. keel_webhookshas no get or update. The API has neither. List them to read one, and replace a subscription by deleting it and creating another.keel_webhookscreatereturns the subscription’s signingsecretonce, with asecretNotetelling the model to hand it to the user.listnever returns it.rotateissues a new secret the same way and returnspreviousSecretExpiresAt: the old secret keeps signing for 24 hours, so a receiver can switch without dropping events.- Each delivery carries
x-keel-event-id,x-keel-subscription-idandx-keel-signature: v1=, the hex HMAC-SHA256 of the raw body keyed with the whole secret. During a rotation window the header holds two comma-separatedv1=values, current first. Recompute the HMAC over the exact bytes received, accept if it equals anyv1=value (constant-time comparison), and deduplicate onx-keel-event-id. keel_webhookslistshows each fulltargetUrlonly to an owner or admin key. Any other key sees just the origin (https://host).keel_vendorsreads the authentication posture (auth) and cannot write it. The underlying update treats any one ofmfa,passwordPolicyandssoas the caller owning all three, so a partial write would silently clear the other two.keel_vendorscreatetakes no owners. SetbusinessOwnerEmailandsecurityOwnerEmailwithupdate; null clears one, and omitting one leaves it. An owner change fires no webhook event, matching the app.keel_evidencecreatecovers link evidence only.POST /evidencealso accepts a file asmultipart/form-data, which is not something a stdio transport streams well; a tool that half-worked would be worse than one that says what it covers. Upload files in the Keel app or against the REST API directly.
keel_readiness answers for ISO/IEC 27001:2022 only, whatever framework the
workspace actually runs. keel_frameworks is the one that answers for the workspace.
Both are here because they are different questions, and the descriptions say which.
Roles
The API key acts as the member who created it. Writes are refused for the auditor
role, and most deletes need owner or admin, matching what the same person can do in
the browser. Every keel_people write needs owner or admin, create included,
because create updates a person who already exists and can change their status.
A key created before keys carried an actor has no member and therefore no role. It can still read; every write answers 403 until the key is re-created under Integrations -> API keys. Deleting a webhook subscription requires owner or admin, where it used to accept any valid key, so a Zapier unsubscribe running on an old key fails and needs a fresh key. Rotating a webhook secret needs owner or admin too.
Unknown arguments are errors
Every tool’s input schema is strict, and each action refuses an argument it does not
send. Before 0.4.0 both were dropped silently and the call still reported success, so a
startDate on a task or a vendorId on evidence vanished without a word. The REST API
does the same on its side: a write whose body carries an undocumented field gets a 400
listing it in unknownFields.
People are named by email where the API resolves them: assigneeEmail on tasks,
ownerEmail on policies, and businessOwnerEmail and securityOwnerEmail on vendors
must match a workspace member (keel_members lists them), or the call fails and nothing
is written. Evidence links to controls through
controlId/controlKey on create and linkControls/unlinkControls on update; a
control that is not in the workspace is an error rather than a missing link.
Tool descriptions quote API field names exactly (the control status field is called
state, not status; a vendor’s tier on the way out is the residual and on the way
in is the inherent) and use enums with the API’s own accepted values, because the
description and schema are the only things the model sees before it calls a tool.
Configuration
Two environment variables:
KEEL_API_KEY: required. Create one under Integrations -> API keys in your Keel workspace.KEEL_BASE_URL: optional, defaults tohttps://app.keelgrc.com. Set it for a self-hosted or preview workspace.
Claude Desktop / Claude Code
Add to your MCP config (claude_desktop_config.json, or .mcp.json for Claude Code):
{
"mcpServers": {
"keel": {
"command": "npx",
"args": ["-y", "keelgrc-mcp"],
"env": { "KEEL_API_KEY": "keel_live_..." }
}
}
}
Cursor
~/.cursor/mcp.json uses the same mcpServers shape.
Develop
npm ci # installs exactly package-lock.json
npm run build # compile to dist/
npm run smoke # boot the built server and assert it speaks MCP
KEEL_API_KEY=... node dist/index.js # run over stdio
npm run smoke needs no API key and makes no network call: it starts dist/index.js,
completes the MCP handshake, and checks the tool list, the advertised version, and that
nothing but protocol frames reach stdout. It runs in CI before every publish.
The server speaks MCP over stdio, so it never writes to stdout except protocol frames; status goes to stderr.
Publishing
This repository is the source of truth for the npm package
keelgrc-mcp. It is published from here via
npm OIDC trusted publishing (.github/workflows/publish.yml): GitHub Actions
authenticates to npm directly, so there is no stored NPM_TOKEN and no 2FA code, and
each release carries build provenance. The first release (0.1.0) was a manual
bootstrap, because trusted publishing can only be enabled for a package that already
exists.
The one-time trusted-publisher setup (npmjs.com -> the package -> Settings -> Trusted Publisher) is documented at the top of the workflow file. To cut a new release:
- Bump
versioninpackage.json(npm rejects re-publishing an existing version). The server reports that same version in its MCP handshake — it readspackage.jsonrather than carrying a copy, so the two cannot drift. - Commit the regenerated
package-lock.jsonin the same change, ornpm cifails. - Actions tab -> “Publish keelgrc-mcp” -> Run workflow, or publish a GitHub Release.
The publish job is deliberately locked down, because it is the one place in Keel that
holds an OIDC token able to publish under Keel’s name with a provenance attestation:
npm ci against a committed lockfile, --ignore-scripts so no dependency’s install
hook runs beside that token, a pinned npm rather than @latest, and an audit that
fails the job instead of a flag that silences it.
Security notes
- The key is sent only to
KEEL_BASE_URLas aBearertoken; nothing is logged. - Access is exactly the key’s org, enforced by Keel’s row-level security, the same as the REST API. Revoke a key under Integrations to cut off the MCP instantly.
License
MIT © Keel GRC LLC. See LICENSE.
Install
npx -y keelgrc-mcpConfiguration
{
"mcpServers": {
"keel": {
"command": "npx",
"args": ["-y", "keelgrc-mcp"],
"env": { "KEEL_API_KEY": "keel_live_..." }
}
}
}