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
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
"@coderscreen/email": "workspace:^",
"@daytonaio/sdk": "0.21.1",
"@hono/zod-validator": "^0.7.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/cloudflare": "10.65.0",
"@tldraw/sync-core": "^3.14.0",
"@tldraw/tlschema": "^3.14.0",
"agents": "^0.19.0",
"better-auth": "^1.2.10",
"drizzle-orm": "^0.44.2",
"hono": "^4.7.11",
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import { openAPISpecs } from 'hono-openapi';
import { useAuth } from '@/lib/auth';
import { getSentryOptions } from '@/lib/sentry';
import { getBilling } from '@/lib/session';
import { CoderScreenMcp } from '@/mcp/agent';
import { handleMcpSSE, handleMcpStreamable } from '@/mcp/handler';
import { apiKeyMiddleware } from '@/middleware/apiKey.middleware';
import { authMiddleware } from '@/middleware/auth.middleware';
import { RoomServer as PartyServer } from '@/partykit/room.do';
import { apiKeyRouter } from '@/routes/apiKey.routes';
import { billingRouter } from '@/routes/billing.routes';
import { templateRouter } from '@/routes/template.routes';
import { publicApiRouter } from '@/routes/v1';
import { webhookRouter } from '@/routes/webhook.routes';
import { PublicRoomSchema } from '@/schema/room.zod';
import { AppFactory, appFactoryMiddleware } from '@/services/AppFactory';
Expand Down Expand Up @@ -83,6 +88,11 @@ const app = new Hono<AppContext>()
except(
[
'/webhook/*',
'/v1/*',
'/mcp',
'/mcp/*',
'/sse',
'/sse/*',
'/rooms/:roomId/public/*',
'/assessments/:subId/take',
'/assessments/:subId/take/*',
Expand All @@ -91,6 +101,17 @@ const app = new Hono<AppContext>()
authMiddleware
)
)
// Public API: separate surface authenticated with an org API key (never a
// session cookie), so keys can't reach the internal routes below.
.use('/v1/*', apiKeyMiddleware)
.route('/v1', publicApiRouter)
// Hosted MCP server wrapping /v1 (same API-key auth). Handlers verify the key
// then hand off to the CoderScreenMcp Durable Object.
.all('/mcp', handleMcpStreamable)
.all('/mcp/*', handleMcpStreamable)
.all('/sse', handleMcpSSE)
.all('/sse/*', handleMcpSSE)
.route('/api-keys', apiKeyRouter)
.route('/webhook', webhookRouter)
.route('/rooms/:roomId/public', publicRoomRouter)
.route('/assets', assetRouter)
Expand Down Expand Up @@ -245,6 +266,7 @@ const InstrumentedWhiteboardDurableObject = Sentry.instrumentDurableObjectWithSe

export {
Sandbox,
CoderScreenMcp,
InstrumentedPartyServer as PartyServer,
InstrumentedPrivateRoomServer as PrivateRoomServer,
InstrumentedWhiteboardDurableObject as WhiteboardDurableObject,
Expand Down
105 changes: 105 additions & 0 deletions apps/api/src/lib/apiKeyAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { apikey } from '@coderscreen/db/apikey.db';
import { eq, sql } from 'drizzle-orm';
import { Context } from 'hono';
import { useDb } from '@/db/client';
import { AppContext } from '@/index';

export interface ApiKeyIdentity {
keyId: string;
organizationId: string;
// The user who created the key. Used only to attribute API-created resources
// (e.g. rooms). The key itself acts for the organization.
createdBy: string;
}

const KEY_PREFIX = 'cs';
// Random part length. 32 chars of the id alphabet is ~190 bits of entropy.
const KEY_RANDOM_LENGTH = 32;
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

/** SHA-256 hex digest, used to store/look up keys without persisting plaintext. */
export const hashApiKey = async (key: string): Promise<string> => {
const data = new TextEncoder().encode(key);
const digest = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

/** Cryptographically-random `cs_<random>` key plus the bits we persist. */
export const generateApiKey = async (): Promise<{
key: string;
keyHash: string;
prefix: string;
start: string;
}> => {
const bytes = crypto.getRandomValues(new Uint8Array(KEY_RANDOM_LENGTH));
const random = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
const key = `${KEY_PREFIX}_${random}`;
return {
key,
keyHash: await hashApiKey(key),
prefix: KEY_PREFIX,
// Enough to disambiguate in the UI without revealing the secret.
start: key.slice(0, KEY_PREFIX.length + 1 + 6),
};
};

/** Pull an API key from an `Authorization: Bearer <key>` or `x-api-key` header. */
export const extractApiKey = (ctx: Context<AppContext>): string | undefined => {
const authHeader = ctx.req.header('authorization');
const bearer = authHeader?.toLowerCase().startsWith('bearer ')
? authHeader.slice(7).trim()
: undefined;
return bearer ?? ctx.req.header('x-api-key');
};

/**
* Verify an API key against the apikey table and resolve the org it acts for.
* Returns null when the key is unknown, disabled, or expired.
*
* Single source of truth for API-key auth, shared by the REST middleware
* (apiKey.middleware.ts) and the MCP handler (mcp/handler.ts).
*/
export const verifyApiKey = async (
ctx: Context<AppContext>,
key: string
): Promise<ApiKeyIdentity | null> => {
const db = useDb(ctx);
const keyHash = await hashApiKey(key);

const row = await db
.select({
id: apikey.id,
createdBy: apikey.createdBy,
organizationId: apikey.organizationId,
enabled: apikey.enabled,
expiresAt: apikey.expiresAt,
})
.from(apikey)
.where(eq(apikey.keyHash, keyHash))
.then((rows) => rows[0] ?? null);

if (!row || !row.enabled) return null;
if (row.expiresAt && new Date(row.expiresAt).getTime() < Date.now()) return null;

// Best-effort usage tracking; never block the request (or fail auth) on it.
const trackUsage = db
.update(apikey)
.set({ lastRequest: sql`now()`, requestCount: sql`${apikey.requestCount} + 1` })
.where(eq(apikey.id, row.id))
.then(() => undefined)
.catch(() => undefined);
try {
ctx.executionCtx.waitUntil(trackUsage);
} catch {
// No execution context (e.g. certain test/runtime paths) - fire and forget.
void trackUsage;
}

return {
keyId: row.id,
organizationId: row.organizationId,
createdBy: row.createdBy,
};
};
28 changes: 28 additions & 0 deletions apps/api/src/mcp/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpAgent } from 'agents/mcp';
import { registerTools } from './executor';

/**
* Per-connection context, populated by the MCP handler after it verifies the
* caller's API key. Tools call `/v1` with `apiKey` against `baseUrl`; the org
* and acting user are re-derived server-side from the key on each `/v1` call,
* so nothing else needs to be stashed here. Declared as a type alias (not an
* interface) so it satisfies McpAgent's `Record<string, unknown>` props
* constraint without an explicit index signature.
*/
export type McpProps = {
apiKey: string;
baseUrl: string;
};

/**
* Hosted MCP server (Durable Object) that wraps the public `/v1` API. Tool
* definitions live in tools.ts; this class only wires them onto the server.
*/
export class CoderScreenMcp extends McpAgent<Env, unknown, McpProps> {
server = new McpServer({ name: 'coderscreen', version: '1.0.0' });

async init(): Promise<void> {
registerTools(this.server, () => this.props);
}
}
49 changes: 49 additions & 0 deletions apps/api/src/mcp/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { McpProps } from './agent';
import { TOOLS } from './tools';

/**
* Registers every tool in the registry on the MCP server. Each tool is a thin
* bridge: it validates args (via the SDK, against the tool's `input` shape),
* calls the corresponding `/v1` endpoint with the caller's API key, and returns
* the raw JSON. `getProps` is read at call time so the caller's key/base URL are
* always current regardless of when the agent hydrated its props.
*/
export const registerTools = (server: McpServer, getProps: () => McpProps | undefined): void => {
for (const tool of TOOLS) {
server.registerTool(
tool.name,
{ description: tool.description, inputSchema: tool.input },
// args are validated by the SDK against inputSchema before this runs
(async (args: Record<string, unknown>) => {
const props = getProps();
if (!props) {
return {
content: [{ type: 'text' as const, text: 'Not authenticated' }],
isError: true,
};
}

const { method, path, body } = tool.request(args ?? {});
const res = await fetch(`${props.baseUrl}/v1${path}`, {
method,
headers: {
authorization: `Bearer ${props.apiKey}`,
'content-type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});

const text = await res.text();
if (!res.ok) {
return {
content: [{ type: 'text' as const, text: `Error ${res.status}: ${text}` }],
isError: true,
};
}
return { content: [{ type: 'text' as const, text }] };
// biome-ignore lint/suspicious/noExplicitAny: registerTool's generic callback type
}) as any
);
}
};
53 changes: 53 additions & 0 deletions apps/api/src/mcp/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppContext } from '@/index';
import { extractApiKey, verifyApiKey } from '@/lib/apiKeyAuth';
import { CoderScreenMcp, McpProps } from './agent';

const MCP_BINDING = 'CODERSCREEN_MCP';

/**
* Verify the caller's API key and stash it (plus this request's origin) on the
* execution context's `props`. This is how the agents SDK passes per-connection
* context to an McpAgent: `McpAgent.serve()` reads `ctx.props` when it spins up
* the Durable Object (the same channel `@cloudflare/workers-oauth-provider`
* uses after an OAuth flow). Throws 401 if the key is missing or invalid.
*/
const authenticate = async (c: Context<AppContext>): Promise<void> => {
const key = extractApiKey(c);
if (!key) {
throw new HTTPException(401, {
message: 'Missing API key. Provide it as "Authorization: Bearer <key>".',
});
}

const identity = await verifyApiKey(c, key);
if (!identity) {
throw new HTTPException(401, { message: 'Invalid API key' });
}

// Only the key and origin are needed; tools re-derive org/user from the key
// on each /v1 call.
const props: McpProps = { apiKey: key, baseUrl: new URL(c.req.url).origin };
c.executionCtx.props = props;
};

/** Streamable HTTP transport (recommended). Single endpoint at /mcp. */
export const handleMcpStreamable = async (c: Context<AppContext>): Promise<Response> => {
await authenticate(c);
return CoderScreenMcp.serve('/mcp', { binding: MCP_BINDING }).fetch(
c.req.raw,
c.env,
c.executionCtx
);
};

/** Legacy SSE transport, for clients that don't support streamable HTTP yet. */
export const handleMcpSSE = async (c: Context<AppContext>): Promise<Response> => {
await authenticate(c);
return CoderScreenMcp.serveSSE('/sse', { binding: MCP_BINDING }).fetch(
c.req.raw,
c.env,
c.executionCtx
);
};
Loading
Loading