Audience: operators deploying @hugr/kernel-based MCP servers.
Last updated: 2026-05-28.
This document describes the threat model, security controls, and operational guidelines for the HuGR toolkit MCP server layer. All controls are implemented in packages/kernel/src/mcp/server.ts and packages/kernel/src/mcp/cors.ts.
| Threat | Severity | Mitigation |
|---|---|---|
| Unauthenticated tool calls | Critical | Bearer token required; IdentityProvider validates every request |
| Unauthorised cross-origin requests (CSRF / XSS pivot) | High | CORS allow-list; wildcard "*" forbidden in production |
| Credential/token leakage in error messages | High | ToolExecutionError + sanitizeForError; raw errors never forwarded |
| Brute-force / DoS via tool spam | Medium | Sliding-window rate limit per user (default: 100 req / 60 s) |
| Privilege escalation (free user calls premium tool) | Medium | Per-tool tier check before handler invocation |
| Invalid or oversized inputs | Low–Medium | Zod schema validation at every tools/call boundary |
| Audit gap — no record of what was called | Low | auditSink hook emits per-call event; no-op default, injectable in prod |
Out of scope for this document: network-layer DDoS (handled by Cloudflare), secret rotation (operator-managed), and platform-level identity federation (see docs/architecture/KERNEL_SPEC.md).
Every tools/call request validates arguments against the tool's Zod schema before the handler is invoked. If validation fails the server returns HTTP 400 / JSON-RPC -32602 with a human-readable details field. Raw Zod errors are formatted and included; no stack traces are forwarded.
Source: packages/kernel/src/mcp/server.ts — Step 8 (Zod validate).
Handler errors are caught and converted through handleError + sanitizeForError (packages/kernel/src/formatter/errors.ts). The resulting ToolError contains only:
error— a stable error-code enum value (e.g."provider_error")message— a safe summary (no paths, tokens, or raw API responses)hint— a user-actionable suggestion
Raw provider error messages, file paths, environment variable values, and API tokens are never forwarded to callers. Handlers must throw ToolExecutionError for structured errors; arbitrary Error throws are caught and downgraded to "internal".
All public types exported from @hugr/kernel/mcp are fully typed. Internal as any casts (e.g. for parsed Zod output passed to handlers) are annotated with a comment.
Credential model: BYOK (bring-your-own-key). Provider credentials are never stored on
a worker. Every worker reads the caller's provider credentials from per-request HTTP
headers (e.g. CF-API-TOKEN, DD-API-KEY + DD-APPLICATION-KEY,
AWS-ACCESS-KEY-ID + AWS-SECRET-ACCESS-KEY + AWS-REGION) and fails closed
("Missing <provider> credentials") when a required header is absent. This is a multi-tenant
requirement: a worker-stored provider key would serve one tenant.s provider data to every
user. The per-request header map is enforced in each worker's buildProvider (see packages/kernel/src/identity/hugr-auth.ts).
| Secret / credential | Location | Access pattern |
|---|---|---|
| HuGR API key (caller identity) | Authorization request header |
Read once in IdentityProvider.authenticate; never stored |
Provider credentials (e.g. CF-API-TOKEN) |
Caller's per-request HTTP header (BYOK) | Lifted into user.extras by hugrAuthIdentity, read by buildProvider per request; never stored on the worker, not logged, not forwarded |
Platform auth (HUGR_INLINE_HMAC_SECRET / HUGR_INTROSPECT_URL) |
Worker env/secret | The toolkit's own bearer-verification config — not a provider credential |
| Dev mode flag | HUGR_DEV_MODE env var |
Bypasses authentication only in development; never set in production Workers |
Rule: process.env reads are only permitted inside buildProvider factories and config helpers. Handler modules and tool modules must not read environment variables directly. Provider credentials are never read from env — they arrive per-request as headers.
createServer accepts corsOrigins: string[] | "*" (field in ServerConfig, packages/kernel/src/mcp/types.ts).
| Value | Behaviour | When to use |
|---|---|---|
"*" (default) |
All origins allowed; Access-Control-Allow-Origin: * |
Development, local tooling, MCP clients without a browser |
string[] |
Only listed origins reflected; others receive 403 | Any production deployment exposed to browser clients |
Warning: When corsOrigins is "*" and NODE_ENV === "production", the kernel emits a console.warn on server startup. This warning is intentional: operators must acknowledge the risk or supply an allow-list.
Source: packages/kernel/src/mcp/cors.ts — warnIfWildcardInProduction.
- OPTIONS preflight:
corsPreflightResponsereturns 204 for allowed origins, 403 for blocked ones. - Non-browser POST (no
Originheader): always allowed, regardless ofcorsOrigins. MCP CLI clients and server-to-server calls do not send anOriginheader; blocking them would break standard usage. - Browser POST from blocked origin: returns 403 JSON-RPC error with
cors_forbiddencode before authentication runs. This prevents any information leakage to disallowed origins. Vary: Origin: set on all responses when an explicit allow-list is active, ensuring correct CDN caching semantics.
corsOrigins: [
"https://your-app.example.com",
"https://staging.example.com",
]
-
corsOriginsis an explicit list (not"*") - The list includes only origins you control
-
NODE_ENV=productionis set so the startup warning fires if the list is accidentally omitted -
HUGR_DEV_MODEis absent or"false"in production
When config.rateLimit is absent, the server applies:
// packages/kernel/src/mcp/types.ts
export const DEFAULT_RATE_LIMIT_POLICY: RateLimitPolicy = {
windowMs: 60_000, // 1-minute sliding window
maxRequests: 100, // per authenticated user.id
};- Scope: per
user.id, not per IP. If a caller has multiple IPs, they share one bucket. - Algorithm: sliding fixed-window (resets fully at
windowStart + windowMs). - Storage: in-memory
Mapin the Worker process. Limits are not shared across Worker instances or restarts. - Response: HTTP 429 / JSON-RPC
-32000witherror: "rate_limited"and a"Resets in Ns."message.
Pass an explicit rateLimit in ServerConfig to loosen or tighten per host:
createServer({
// ...
rateLimit: { windowMs: 60_000, maxRequests: 30 }, // stricter: 30 req/min
});Setting maxRequests very high (e.g. Infinity) effectively disables rate limiting — not recommended for production.
Rate limits apply after the tier check. A free user calling a premium tool is rejected at tier-check before consuming a rate-limit slot.
After every successful authenticated tools/call dispatch, the server calls config.auditSink with an AuditEvent:
// packages/kernel/src/mcp/types.ts
interface AuditEvent {
ts: string; // ISO-8601 timestamp
userId: string; // authenticated user.id
plan: string; // "free" | "pro" | ...
toolName: string; // name of the tool invoked
status: "ok" | "error"; // "error" = handler threw, result is graceful degradation
durationMs: number; // wall-clock handler time
}The default auditSink is () => undefined. No audit events are emitted or stored unless a real sink is wired in.
Inject a sink in ServerConfig when creating the server:
// Example: Cloudflare Analytics Engine
createServer({
// ...
auditSink: (event) => {
analyticsDataset.writeDataPoint({
blobs: [event.userId, event.toolName, event.status],
doubles: [event.durationMs],
indexes: [event.plan],
});
},
});- Unauthenticated requests (rejected before a user is known)
- CORS preflight OPTIONS requests
initializeandtools/listmethod calls (informational, no data access)- Tier-check rejections (logged separately by
ToolExecutionErrorpattern if desired)
To report a security issue, email the maintainer at the address in package.json. Do not open a public GitHub issue for vulnerabilities. We target a 7-day initial response for high-severity reports.
File path: docs/SECURITY.md — owned by . Update this document when rate-limit defaults, CORS policy, or audit shape changes.