A standalone MCP aggregation layer for Render users. One endpoint, many tools — discovered via search_tools, governed by RBAC, backed by a code registry.
Agents connect to a single URL instead of configuring Render, GitHub, Slack, and custom MCP servers separately.
- One Web Service — gateway, search, and all provider adapters run in-process
- One Postgres — audit log and API key → role mapping (not the tool catalog)
- Code registry — approved tools and policy live in code and load at startup
- One Cron Job — bounded audit retention and interrupted-call reconciliation
Agent → POST /mcp → search_tools → get_tool_schema → tools/call → provider adapter
tools/list intentionally returns only meta-tools. Server instructions tell agents to use progressive discovery. Remote provider calls use fresh, stateless MCP clients; every resource scope must be supplied in the tool arguments.
The public /mcp endpoint uses MCP Streamable HTTP in stateless JSON-response
mode; protocol-level sessions are not persisted between requests.
- Node.js 22+
- Docker (local Postgres only)
- Render account (dashboard.render.com)
- Render CLI (optional, for Blueprint deploy):
brew install render && render login
git clone https://github.com/render-examples/mcp-toolshed.git
cd mcp-toolshed
cp .env.example .envEdit .env — at minimum set:
| Variable | Local value | Notes |
|---|---|---|
DATABASE_URL |
postgresql://toolshed:toolshed@localhost:5433/toolshed |
Matches docker-compose.yml |
| (no bootstrap key) | — | db:migrate generates and prints an admin key when api_keys is empty |
RENDER_API_KEY |
your Render API key | Enables the Render provider |
Generate a production-grade key:
openssl rand -hex 32docker compose up -d postgres # start Postgres on port 5433
npm install
npm run db:migrate # applies migrations + inserts bootstrap key
npm run dev # http://localhost:3000npm run build compiles the production application to dist/. The Docker
image runs that JavaScript directly; tsx is used only by local development
and maintenance scripts.
Verify:
curl http://localhost:3000/ready
# → {"status":"ok","toolCount":N}If you have no provider credentials yet, add to .env:
TOOLSHED_ALLOW_EMPTY=true
The repo includes a Blueprint (render.yaml) that creates one Web Service, one Postgres database, and one daily audit-cleanup Cron Job.
render blueprint launchSelect your workspace and confirm resource creation when prompted.
- Go to dashboard.render.com → New → Blueprint
- Connect the
render-examples/mcp-toolshedrepository - Review the three resources (
mcp-toolshed,toolshed-db,mcp-toolshed-audit-cleanup) and apply
Render prompts for these (sync: false in render.yaml):
| Variable | Required | Purpose |
|---|---|---|
| (none for auth) | — | The initial admin key is generated by migrate, not supplied |
RENDER_API_KEY |
Recommended | Enables Render MCP tools (render.*) |
GITHUB_TOKEN |
Optional | Enables GitHub tools through GitHub's hosted MCP server (github.*) |
GITHUB_MCP_URL |
Optional | Defaults to https://api.githubcopilot.com/mcp/ |
SLACK_BOT_TOKEN |
Optional | Slack bot token (xoxb-...) — see Slack setup |
SLACK_TEAM_ID |
With Slack | Workspace ID (T...) — required with SLACK_BOT_TOKEN |
SLACK_CHANNEL_IDS |
Optional | Comma-separated channel IDs to limit access |
RENDER_MCP_URL |
Optional | Defaults to https://mcp.render.com/mcp |
TICKET_API_URL / TICKET_API_KEY |
Optional | Enables custom inline provider stub |
TOOLSHED_AUTH_CACHE_TTL_MS |
Optional | API-key lookup cache; defaults to 10000. 0 disables it for instant revocation |
TOOLSHED_AUDIT_RETENTION_DAYS |
Optional | Audit retention; defaults to 30 days |
TOOLSHED_AUDIT_CLEANUP_MAX_MINUTES |
Optional | Wall-clock budget for the cleanup cron; defaults to 10 |
TOOLSHED_AUDIT_MAX_ARGUMENT_BYTES |
Optional | Maximum stored argument payload; defaults to 65536 bytes |
TOOLSHED_ALLOWED_ORIGINS |
Optional | Comma-separated browser origins allowed to call /mcp; requests with other origins are rejected |
TOOLSHED_ALLOWED_HOSTS |
With custom domains | Comma-separated custom hostnames accepted by /mcp |
Health check note: /ready returns 200 only when Postgres is up and at least one provider loaded tools. For a working deploy, set RENDER_API_KEY (or another provider credential). Use TOOLSHED_ALLOW_EMPTY=true only for dev/testing.
Plan note: Use Starter or higher for the web service. Free tier spins down after inactivity.
- Confirm health:
curl https://<your-service>.onrender.com/ready - Copy the admin key from the deploy log —
db:migrateprints it once, on the first deploy only, and it is never recoverable afterwards. If you miss it, revoke thebootstraprow and insert a new key (see RBAC). - Connect your MCP client (see below)
- Add more API keys via Postgres if needed (see RBAC)
Your toolshed URL:
https://<your-service>.onrender.com/mcp
All requests require:
Authorization: Bearer <your-api-key>
Add to MCP settings (.cursor/mcp.json or Cursor Settings → MCP):
{
"mcpServers": {
"toolshed": {
"url": "https://<your-service>.onrender.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Use the same URL and Authorization header. Clients must support MCP Streamable HTTP.
After connecting, agents discover tools progressively:
1. tools/list → [search_tools, get_tool_schema]
2. search_tools → { "pattern": "list.*service" }
3. get_tool_schema → { "name": "render.list_services" }
4. tools/call → { "name": "render.list_services", "arguments": { ... } }
search_tools takes an RE2 regular
expression, matched case-insensitively against each tool's full definition —
name, description, and serialized input schema. There is no relevance
ranking and no curated synonym list: a tool either matches or it does not, so
express synonyms yourself with alternation.
"deploy|restart|redeploy""^github\\.(create|update)""database|postgres|sql""workspaceId"— every tool that takes a workspace argument"."— list every tool the key can use
Because the schema is part of the corpus, a pattern can select on parameters and enum values, not just prose.
Backreferences and lookaround are rejected; RE2 has no backtracking, which is what keeps a caller-supplied pattern from stalling the server. An empty result means nothing matched the pattern, not that the capability is missing — broaden the pattern before giving up.
Providers are TypeScript modules in providers/. Each is enabled when its env vars are set.
| Provider | File | Enable with |
|---|---|---|
| Render | providers/render.ts |
RENDER_API_KEY |
| GitHub | providers/github.ts |
GITHUB_TOKEN (official hosted MCP) |
| Slack | providers/slack.ts |
SLACK_BOT_TOKEN + SLACK_TEAM_ID (direct Web API adapter) |
| Custom | providers/custom.ts |
TICKET_API_URL + TICKET_API_KEY |
To add a new provider:
- Create
providers/my-api.ts(copy a stub) - Add every approved remote/stdio tool to its explicit
toolPolicy, marking eachmutates: trueormutates: false; unlisted upstream tools are ignored - Register it in
providers/index.ts - Grant access in
config/rbac.ts. Roles scoped byprefixesreach only the providers they name, so a new provider is invisible toimplementeruntil you add its prefix.analystandadmindeclare no prefixes and pick it up automatically — read-only and full respectively. - Commit, push, and redeploy
- Create an app at api.slack.com/apps → From scratch
- OAuth & Permissions → add bot scopes:
channels:history,channels:read,chat:write,reactions:write,users:read,users.profile:read - Install to Workspace → copy the Bot User OAuth Token (
xoxb-...) - Get your Workspace ID (
T...) from Slack workspace settings - Set on the Render service (or local
.env):
SLACK_BOT_TOKEN=xoxb-...
SLACK_TEAM_ID=T...
- Redeploy, then
/invite @your-botin any channel the agent should use
| Symptom | Fix |
|---|---|
/ready returns 503 |
Set a provider credential (RENDER_API_KEY) or TOOLSHED_ALLOW_EMPTY=true |
401 on /mcp |
Check Authorization: Bearer … matches a key in api_keys |
| Deploy stuck on health check | Postgres not ready, or zero providers loaded — check logs |
| No Render tools in search | Verify RENDER_API_KEY is set and service restarted after adding it |
| No Slack tools in search | Set both SLACK_BOT_TOKEN and SLACK_TEAM_ID, then redeploy |
| Bootstrap key stopped working | Check whether the key was revoked or expired in api_keys; migrations never overwrite an existing key set |
View logs in the Render Dashboard → mcp-toolshed → Logs.
See Setup guide §6 for the workflow. Provider types:
| Type | Use for |
|---|---|
mcp-remote |
Hosted MCP servers (Render MCP, GitHub MCP) |
mcp-stdio |
Locally installed MCP binaries that require stdio |
inline |
Custom REST APIs with TypeScript handlers |
GitHub uses GitHub's maintained hosted MCP endpoint with PAT authentication. Slack uses a small in-process Web API adapter so bot-token deployments do not depend on the archived reference MCP package.
Policy lives in config/rbac.ts. Roles: analyst, implementer, admin.
Remote and stdio tools are deny-by-default. Their exact upstream names,
whether they mutate state, and required scoping arguments are declared in
config/tool-policy.ts. New or renamed upstream tools remain unavailable
until reviewed.
The split is deliberate: config/tool-policy.ts holds facts about each tool
that hold regardless of who deploys this, while config/rbac.ts holds the
decisions about which identities reach which providers.
Each role has exactly one access rule, used for both discovery and execution. A key is never shown a tool it cannot run, so search results are always callable and there is no separate "visible but denied" state to reason about.
That single rule is enforced at three layers, because each is reachable on its own:
search_tools— filters the catalog before matching the patternget_tool_schema— denied if the key may not use the tooltools/call— denied if the key may not use the tool
Insert additional API keys (hash must match Node sha256 of the UTF-8 key string):
node -e "const c=require('crypto'); console.log(c.createHash('sha256').update('my-secret-key').digest('hex'))"INSERT INTO api_keys (key_hash, role, label)
VALUES ('<hash-from-above>', 'implementer', 'ci-bot');Keys can be time-bounded with expires_at or revoked without deleting audit
history:
UPDATE api_keys SET revoked_at = now() WHERE label = 'ci-bot';Revocation takes effect within TOOLSHED_AUTH_CACHE_TTL_MS (default 10s).
Set it to 0 for immediate revocation at the cost of a database round trip on
every request.
Roles decide which tools a key may call. Scopes decide which resources it
may touch with them — without these, any key that reaches render.* can act on
every workspace the shared RENDER_API_KEY can see.
A tool declares which argument names its resource (scopeArgument in
config/tool-policy.ts; workspaceId for Render). A key declares which
resources it may use:
INSERT INTO api_key_scopes (api_key_id, provider_id, resource_id)
VALUES (2, 'render', 'tea-abc123');A key with no rows for a provider is unrestricted on that provider, so
scoping is opt-in and existing keys keep working. Once a key has a scope row,
calls whose scopeArgument is absent or unlisted are denied before execution
and recorded as denied.
render.list_workspaces takes no workspaceId and is therefore unscoped: a
scoped key can still see that other workspaces exist, it just cannot act on
them.
GET /ready— public, minimal response; 200 when Postgres is up and at least one approved provider tool is loaded (orTOOLSHED_ALLOW_EMPTY=true)GET /health— admin-authenticated diagnostics with per-provider and audit-pipeline state
Optional provider failures report degraded health without removing healthy providers from service. Provider calls use bounded queues whose deadlines begin at admission, and cancellation propagates to upstream clients.
Arguments are recursively redacted before storage, including nested secret
objects and environment-variable key/value pairs. Oversized payloads are
truncated. Write operations require a durable started audit record before
execution. The daily Cron Job marks abandoned intents unknown and deletes
expired rows in bounded batches.
providers/ # Tool sources — edit these
config/ # Role policies and explicit remote-tool allowlists
toolshed/ # Core server (don't fork unless extending)
migrations/ # Postgres schema (audit + api_keys)