Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,14 @@ MCP_TRANSPORT=stdio
# API & MCP HTTP Auth (REST API and MCP endpoint when MCP_TRANSPORT=http)
# ============================================================================
# Comma-separated list of valid API keys. Clients send via header or Authorization: Bearer <key>
# These grant full access: read, write and delete.
# API_KEYS=key1,key2

# Read-only API keys. These authenticate, but are limited to GET on the REST API
# and to non-write MCP tools. Use these for external consumers of a dataset.
# A key listed in both variables is treated as read-only.
# API_KEYS_READONLY=readonly-key1,readonly-key2

# Header for API key (default: authorization). Clients send Authorization: Bearer <key>. Set to x-api-key to use that header instead.
# API_KEY_HEADER=authorization

Expand Down
3 changes: 2 additions & 1 deletion docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ The vCon MCP Server exposes a RESTful HTTP API alongside the MCP transport layer

| Variable | Default | Description |
|----------|---------|-------------|
| `API_KEYS` | (none) | Comma-separated list of valid API keys |
| `API_KEYS` | (none) | Comma-separated API keys with full read/write/delete access |
| `API_KEYS_READONLY` | (none) | Comma-separated read-only API keys — authenticate, but any non-GET request returns `403 Forbidden` |
| `API_KEY_HEADER` | `authorization` | Header for API key; default expects `Authorization: Bearer <token>`. Set to `x-api-key` to use that header instead. |
| `API_AUTH_REQUIRED` | `true` | Set to `false` to disable authentication |

Expand Down
41 changes: 41 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,47 @@ to PostgREST exposed schemas.
See [Multi-Supabase Isolation](multi-supabase-isolation.md) for complete patterns,
the security trade-off, and provisioning scripts.

#### API Keys and Read-Only Access

Both the REST API and the MCP HTTP endpoint authenticate with the same keys.

```bash
# Full access: read, write, delete
API_KEYS=ops-key-1,ops-key-2

# Read-only: authenticates, but cannot mutate anything
API_KEYS_READONLY=partner-key-1,partner-key-2

# Header used for the key (default: authorization, i.e. Authorization: Bearer <key>)
API_KEY_HEADER=authorization

# Require auth (default: true)
API_AUTH_REQUIRED=true
```

What a read-only key can do:

| Surface | Allowed | Rejected |
|---------|---------|----------|
| REST | `GET`, `HEAD`, `OPTIONS` on any route | every `POST`, `PUT`, `PATCH`, `DELETE` → `403 Forbidden` |
| MCP | tools in the `read`, `schema`, `analytics`, `infra` categories | tools in the `write` category (not listed, and `tools/call` fails) |

Notes:

- Read-only keys are the credential to hand an external consumer of a hosted
dataset. `API_KEYS` tokens can delete the whole corpus in one request.
- A token listed in both variables is treated as read-only (deny wins).
- The read-only tool set is derived from the same categories as
`MCP_TOOLS_PROFILE` (see below) minus `write`, so `MCP_DISABLED_CATEGORIES`
and `MCP_DISABLED_TOOLS` still apply on top.
- An MCP session is pinned to the scope of the key that opened it; a request
carrying a session ID created under a different scope gets `403`.
- `API_KEYS` alone stays full access for backward compatibility. When no
read-only keys are configured, the server logs a warning at startup.
- REST reads are all `GET`, including search and analytics. The one read-ish
`POST` is `/database/analyze` (query plans), which read-only keys cannot use
over REST — the equivalent `analyze_query` MCP tool is available.

#### Tool Categories

Control which tools are available in your deployment:
Expand Down
13 changes: 13 additions & 0 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key-here
```

If you expose the HTTP transport (REST API and MCP over HTTP), also set API keys:

```env
# Full access: read, write, delete
API_KEYS=ops-key-1
# Read-only: GET-only on REST, no write MCP tools. Give these to consumers.
API_KEYS_READONLY=partner-key-1
```

`API_KEYS` tokens can delete the whole corpus, so hand out `API_KEYS_READONLY`
tokens to anyone who only needs to read. See
[Configuration → API Keys and Read-Only Access](configuration.md#api-keys-and-read-only-access).

**Getting Supabase Credentials:**

1. Go to [supabase.com](https://supabase.com) and sign in
Expand Down
6 changes: 6 additions & 0 deletions docs/var/02-installation-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,18 @@ cd vcon-mcp && npm install && npm run build
-e MCP_TRANSPORT=http \
-e MCP_HTTP_HOST=0.0.0.0 \
-e API_KEYS='customer-key-1' \
-e API_KEYS_READONLY='partner-readonly-key-1' \
public.ecr.aws/r4g1k2s3/vcon-dev/vcon-mcp:1.2.0
```

6. **Verify.** `curl http://localhost:3000/api/v1/health` returns `{"status":"ok"}`
with `X-Version` and `X-Git-Commit` response headers.

`API_KEYS` grants full read/write/delete. Hand external consumers a key from
`API_KEYS_READONLY` instead: those keys get `403` on every non-GET REST request
and cannot call write MCP tools. See
[Configuration Guide → Authentication](./03-configuration-guide.md#authentication).

### Transport choice

| Transport | When to use | How to launch |
Expand Down
13 changes: 10 additions & 3 deletions docs/var/03-configuration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,21 @@ API key auth covers both REST and MCP HTTP endpoints.
| Variable | Default | Meaning |
|---|---|---|
| `API_AUTH_REQUIRED` | `true` | Require auth on REST + MCP HTTP |
| `API_KEYS` | — | Comma-separated valid keys |
| `API_KEYS` | — | Comma-separated keys with full read/write/delete access |
| `API_KEYS_READONLY` | — | Comma-separated read-only keys (GET-only on REST, no write tools on MCP) |
| `API_KEY_HEADER` | `authorization` | Header to read key from |

Default header `authorization` accepts `Authorization: Bearer <key>`. Set
`API_KEY_HEADER=x-api-key` to use a plain custom header.

**Misconfiguration trap:** `API_AUTH_REQUIRED=true` with empty `API_KEYS`
returns `503 Service Unavailable` until a key is set.
**Misconfiguration trap:** `API_AUTH_REQUIRED=true` with no keys in either
variable returns `503 Service Unavailable` until a key is set.

**Read-only keys.** Give external consumers an `API_KEYS_READONLY` key. Those
keys get `403 Forbidden` on any non-GET REST request and cannot call `write`
category MCP tools. `API_KEYS` keys can delete the entire corpus, so never hand
one out. A key in both variables is read-only. Details:
[Configuration → API Keys and Read-Only Access](../guide/configuration.md#api-keys-and-read-only-access).

## Multi-tenant (RLS)

Expand Down
75 changes: 59 additions & 16 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,55 @@ import type { Context, Next } from 'koa';
import { logWithContext } from '../observability/instrumentation.js';

export interface AuthConfig {
/** API keys that are allowed (comma-separated in env) */
/** Full-access API keys (comma-separated in env API_KEYS) */
apiKeys: string[];
/** Read-only API keys (comma-separated in env API_KEYS_READONLY) */
readonlyKeys: string[];
/** Header name for API key (default: authorization, i.e. Authorization: Bearer <token>). */
headerName: string;
/** Whether auth is required (default: true) */
required: boolean;
}

/**
* Get auth configuration from environment
*/
export function getAuthConfig(): AuthConfig {
const apiKeysEnv = process.env.API_KEYS || '';
const apiKeys = apiKeysEnv
function splitKeys(env: string | undefined): string[] {
return (env || '')
.split(',')
.map(k => k.trim())
.filter(k => k.length > 0);
}

/**
* Get auth configuration from environment.
*
* API_KEYS keeps full read/write access (backward compatible). API_KEYS_READONLY
* tokens authenticate but may only read. A token listed in both is treated as
* read-only (deny wins).
*/
export function getAuthConfig(): AuthConfig {
const readonlyKeys = splitKeys(process.env.API_KEYS_READONLY);
const apiKeys = splitKeys(process.env.API_KEYS).filter(k => !readonlyKeys.includes(k));

return {
apiKeys,
readonlyKeys,
headerName: process.env.API_KEY_HEADER || 'authorization',
required: process.env.API_AUTH_REQUIRED !== 'false',
};
}

/** True if the token is a configured read-only key. */
export function isReadonlyToken(config: AuthConfig, token: string): boolean {
return config.readonlyKeys.includes(token);
}

/** All tokens that authenticate, whatever their scope. */
function allKeys(config: AuthConfig): string[] {
return [...config.apiKeys, ...config.readonlyKeys];
}

/** HTTP methods a read-only token may use on the REST API. */
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

/** Lower-case header name for lookup (Node headers are lower-cased) */
function getHeader(req: IncomingMessage, name: string): string | undefined {
const raw = req.headers[name.toLowerCase()];
Expand All @@ -57,7 +81,7 @@ export function getTokenFromRequest(req: IncomingMessage, headerName: string): s
}

export type ValidateHttpAuthResult =
| { ok: true }
| { ok: true; readonly: boolean }
| { ok: false; statusCode: number; body: object; wwwAuth?: string };

/**
Expand All @@ -69,9 +93,9 @@ export function validateHttpRequestAuth(
config: AuthConfig
): ValidateHttpAuthResult {
if (!config.required) {
return { ok: true };
return { ok: true, readonly: false };
}
if (config.apiKeys.length === 0) {
if (allKeys(config).length === 0) {
logWithContext('error', 'MCP auth required but no API keys configured - blocking request', {
hint: 'Set API_KEYS, or set API_AUTH_REQUIRED=false',
});
Expand Down Expand Up @@ -104,7 +128,7 @@ export function validateHttpRequestAuth(
},
};
}
if (!config.apiKeys.includes(token)) {
if (!allKeys(config).includes(token)) {
logWithContext('warn', 'Invalid MCP auth token attempted', {
remote_address: req.socket?.remoteAddress,
token_prefix: token.substring(0, 8) + '...',
Expand All @@ -116,7 +140,7 @@ export function validateHttpRequestAuth(
body: { error: 'Unauthorized', message: 'Invalid token' },
};
}
return { ok: true };
return { ok: true, readonly: isReadonlyToken(config, token) };
}

/**
Expand All @@ -134,7 +158,7 @@ export function createAuthMiddleware(config?: Partial<AuthConfig>) {

// Auth is required but no API keys are configured - this is a misconfiguration
// Block requests with a clear error rather than silently allowing access
if (authConfig.apiKeys.length === 0) {
if (allKeys(authConfig).length === 0) {
logWithContext('error', 'API auth required but no API keys configured - blocking request', {
path: ctx.path,
hint: 'Set API_KEYS environment variable, or set API_AUTH_REQUIRED=false to disable auth',
Expand Down Expand Up @@ -171,8 +195,8 @@ export function createAuthMiddleware(config?: Partial<AuthConfig>) {
return;
}

// Check if API key is valid
if (!authConfig.apiKeys.includes(apiKey)) {
// Check if API key is valid (full-access or read-only)
if (!allKeys(authConfig).includes(apiKey)) {
logWithContext('warn', 'Invalid API key attempted', {
remote_address: ctx.ip,
api_key_prefix: apiKey.substring(0, 8) + '...',
Expand All @@ -187,8 +211,27 @@ export function createAuthMiddleware(config?: Partial<AuthConfig>) {
return;
}

// Store API key in state for downstream use
// Store API key + scope in state for downstream use
ctx.state.apiKey = apiKey;
ctx.state.readonly = isReadonlyToken(authConfig, apiKey);

// Read-only tokens may only read. Method-based, so every current and future
// mutating route is covered without a per-route allowlist.
if (ctx.state.readonly && !READ_METHODS.has(ctx.method.toUpperCase())) {
logWithContext('warn', 'Read-only API key attempted a write', {
remote_address: ctx.ip,
method: ctx.method,
path: ctx.path,
api_key_prefix: apiKey.substring(0, 8) + '...',
});
ctx.status = 403;
ctx.body = {
error: 'Forbidden',
message: `Read-only API key cannot ${ctx.method} ${ctx.path}. Read-only keys are limited to GET requests.`,
};
return;
}

await next();
};
}
Expand Down
14 changes: 14 additions & 0 deletions src/config/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ export function loadToolsConfig(): ToolsConfig {
};
}

/**
* Restrict a config to read-only tools, for read-only API keys.
*
* Reuses the existing category metadata: drop 'write' and keep the rest, which
* matches the REST rule (read-only keys get GETs) without a second per-tool
* classification.
*/
export function restrictToReadonly(config: ToolsConfig): ToolsConfig {
return {
...config,
enabledCategories: config.enabledCategories.filter((c) => c !== 'write'),
};
}

/**
* Filter tools based on configuration
*/
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ async function main() {

// A Server binds to one transport, so HTTP needs a fresh one per
// session (stateful) / per request (stateless).
httpServerInstance = await startHttpServer(() => {
httpServerInstance = await startHttpServer(({ readonly }) => {
const server = createServer();
registerHandlers({ ...serverContext, server });
registerHandlers({ ...serverContext, server }, { readonly });
return server;
}, config);

Expand Down
28 changes: 20 additions & 8 deletions src/server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import {
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import { loadToolsConfig, filterEnabledTools, stripCategories, type ToolDefinition } from '../config/tools.js';
import {
loadToolsConfig,
filterEnabledTools,
restrictToReadonly,
stripCategories,
type ToolDefinition,
} from '../config/tools.js';
import { logWithContext } from '../observability/instrumentation.js';
import { RequestContext } from '../hooks/plugin-interface.js';
import type { ToolHandlerContext } from '../tools/handlers/index.js';
Expand All @@ -35,8 +41,10 @@ import type { ServerContext } from './setup.js';
* Register all MCP request handlers
*
* @param context - Full server context (uses subset for tool handlers)
* @param options.readonly - Restrict this server to read-only tools (used when
* the HTTP session authenticated with a read-only API key).
*/
export function registerHandlers(context: ServerContext): void {
export function registerHandlers(context: ServerContext, options: { readonly?: boolean } = {}): void {
const { server, queries, pluginManager, handlerRegistry } = context;

// Tool handler context - subset of ServerContext that handlers need
Expand All @@ -51,7 +59,8 @@ export function registerHandlers(context: ServerContext): void {
};

// Load tools configuration once at startup
const toolsConfig = loadToolsConfig();
const baseToolsConfig = loadToolsConfig();
const toolsConfig = options.readonly ? restrictToReadonly(baseToolsConfig) : baseToolsConfig;

// List tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
Expand Down Expand Up @@ -79,8 +88,9 @@ export function registerHandlers(context: ServerContext): void {
// Filter based on configuration
const enabledTools = filterEnabledTools(allToolsWithCategories, toolsConfig);

// Get plugin tools (plugins don't have categories, always included if available)
const pluginTools = await pluginManager.getAdditionalTools();
// Get plugin tools (plugins have no category, so a read-only session drops
// them rather than guess whether they mutate)
const pluginTools = options.readonly ? [] : await pluginManager.getAdditionalTools();

// Strip category field for MCP response (MCP doesn't need it)
const toolsForResponse = [
Expand Down Expand Up @@ -128,7 +138,9 @@ export function registerHandlers(context: ServerContext): void {
const isCategoryDisabled = !toolsConfig.enabledCategories.includes(toolDef.category);

let errorMessage: string;
if (isExplicitlyDisabled) {
if (options.readonly && toolDef.category === 'write') {
errorMessage = `Tool '${name}' requires write access, but this session authenticated with a read-only API key.`;
} else if (isExplicitlyDisabled) {
errorMessage = `Tool '${name}' is explicitly disabled via MCP_DISABLED_TOOLS configuration.`;
} else if (isCategoryDisabled) {
errorMessage = `Tool '${name}' is disabled. Category '${toolDef.category}' is not enabled in current configuration.`;
Expand All @@ -148,8 +160,8 @@ export function registerHandlers(context: ServerContext): void {
return handler.handle(args, handlerContext) as any;
}

// Check if this is a plugin tool
const pluginTools = await pluginManager.getAdditionalTools();
// Check if this is a plugin tool (never for read-only sessions, see above)
const pluginTools = options.readonly ? [] : await pluginManager.getAdditionalTools();
const pluginTool = pluginTools.find((t) => t.name === name);

if (pluginTool) {
Expand Down
Loading
Loading