| 1 | import type { AuthInfo } from '@modelcontextprotocol/server';
|
| 2 | import { createMcpHandler, withMcpAuth } from 'mcp-handler';
|
| 3 | import { MemoryAuditStore, TrustEngine } from '../packages/core/src/index.js';
|
| 4 | import {
|
| 5 | OPENCLASP_MCP_INSTRUCTIONS,
|
| 6 | registerOpenClaspTools,
|
| 7 | } from '../packages/mcp-server/src/server.js';
|
| 8 | import { HostedRepository } from '../packages/persistence/src/hosted.js';
|
| 9 | import { verifyAuth0Token } from './auth0.js';
|
| 10 | import { oauthStore } from './oauth-store.js';
|
| 11 | import {
|
| 12 | DEFAULT_MCP_AUTH_SCOPES,
|
| 13 | assertScopes,
|
| 14 | requiredMcpRequestScopes,
|
| 15 | } from './access-control.js';
|
| 16 | import { guardRequest } from './request-security.js';
|
| 17 | import { assertProductionConfiguration } from './production-config.js';
|
| 18 |
|
| 19 | assertProductionConfiguration('mcp');
|
| 20 |
|
| 21 | const repository = process.env.DATABASE_URL
|
| 22 | ? new HostedRepository(process.env.DATABASE_URL)
|
| 23 | : undefined;
|
| 24 | const engines = new Map<string, Promise<TrustEngine>>();
|
| 25 |
|
| 26 | function engineFor(context: { http?: { authInfo?: { extra?: Record<string, unknown> } } }) {
|
| 27 | const operatorId =
|
| 28 | context.http?.authInfo?.extra?.operatorId ?? context.http?.authInfo?.extra?.userId;
|
| 29 | if (typeof operatorId !== 'string' || !repository) return Promise.resolve(new TrustEngine());
|
| 30 | let pending = engines.get(operatorId);
|
| 31 | if (!pending) {
|
| 32 | pending = repository.list(operatorId).then((rows) => {
|
| 33 | const store = new MemoryAuditStore();
|
| 34 | for (const row of rows) {
|
| 35 | if (row.kind !== 'interaction') store.append(row.kind, row.recordId, row.payload);
|
| 36 | }
|
| 37 | return new TrustEngine(store);
|
| 38 | });
|
| 39 | engines.set(operatorId, pending);
|
| 40 | }
|
| 41 | return pending;
|
| 42 | }
|
| 43 |
|
| 44 | const mcp = createMcpHandler(
|
| 45 | (server) =>
|
| 46 | registerOpenClaspTools(
|
| 47 | server,
|
| 48 | engineFor,
|
| 49 | repository
|
| 50 | ? (operatorId, kind, recordId, value) =>
|
| 51 | repository.upsert(operatorId, kind, recordId, value)
|
| 52 | : undefined,
|
| 53 | repository,
|
| 54 | repository,
|
| 55 | ),
|
| 56 | {
|
| 57 | serverInfo: { name: 'openclasp', version: '0.1.0' },
|
| 58 | instructions: OPENCLASP_MCP_INSTRUCTIONS,
|
| 59 | verboseLogs: true,
|
| 60 | },
|
| 61 | );
|
| 62 |
|
| 63 | async function verifyToken(request: Request, bearerToken?: string): Promise<AuthInfo | undefined> {
|
| 64 | if (!bearerToken) return undefined;
|
| 65 | const requiredScopes = await requiredMcpRequestScopes(request);
|
| 66 | if (bearerToken.startsWith('oc_oat_')) {
|
| 67 | const authentication = await oauthStore().verifyAccessToken(bearerToken);
|
| 68 | if (!authentication) throw new Error('OpenClasp OAuth token is invalid');
|
| 69 | assertScopes(authentication.scopes, requiredScopes);
|
| 70 | return {
|
| 71 | token: bearerToken,
|
| 72 | clientId: authentication.clientId,
|
| 73 | scopes: authentication.scopes,
|
| 74 | expiresAt: Math.floor(Date.parse(authentication.expiresAt) / 1000),
|
| 75 | extra: {
|
| 76 | operatorId: authentication.operatorId,
|
| 77 | credentialType: 'oauth_access_token',
|
| 78 | },
|
| 79 | };
|
| 80 | }
|
| 81 | if (bearerToken.startsWith('oc_at_')) {
|
| 82 | if (!repository) throw new Error('Agent access tokens are not configured');
|
| 83 | const authentication = await repository.verifyAgentAccessToken(bearerToken);
|
| 84 | assertScopes(authentication.scopes, requiredScopes);
|
| 85 | return {
|
| 86 | token: bearerToken,
|
| 87 | clientId: authentication.clientId,
|
| 88 | scopes: authentication.scopes,
|
| 89 | expiresAt: Math.floor(Date.parse(authentication.expiresAt) / 1000),
|
| 90 | extra: {
|
| 91 | operatorId: authentication.operatorId,
|
| 92 | boundAgentId: authentication.agentId,
|
| 93 | credentialType: 'agent_access_token',
|
| 94 | },
|
| 95 | };
|
| 96 | }
|
| 97 | const authentication = await verifyAuth0Token(bearerToken, {
|
| 98 | dashboard: false,
|
| 99 | requiredScopes,
|
| 100 | });
|
| 101 | return {
|
| 102 | token: bearerToken,
|
| 103 | clientId: authentication.clientId,
|
| 104 | scopes: authentication.scopes,
|
| 105 | ...(typeof authentication.payload.exp === 'number'
|
| 106 | ? { expiresAt: authentication.payload.exp }
|
| 107 | : {}),
|
| 108 | extra: { operatorId: authentication.payload.sub },
|
| 109 | };
|
| 110 | }
|
| 111 |
|
| 112 | const resourceMetadataPath = '/.well-known/oauth-protected-resource/mcp';
|
| 113 |
|
| 114 | const authenticatedHandler = withMcpAuth(mcp, verifyToken, {
|
| 115 | required: true,
|
| 116 | resourceMetadataPath,
|
| 117 | });
|
| 118 |
|
| 119 | async function handler(request: Request): Promise<Response> {
|
| 120 | if (request.method === 'POST') {
|
| 121 | const rejected = await guardRequest(request, 'mcp', {
|
| 122 | limit: 300,
|
| 123 | maximumBytes: 256 * 1024,
|
| 124 | });
|
| 125 | if (rejected) return rejected;
|
| 126 | }
|
| 127 | const response = await authenticatedHandler(request);
|
| 128 | if (response.status >= 500) {
|
| 129 | console.error('[mcp.http] request failed', {
|
| 130 | status: response.status,
|
| 131 | method: request.method,
|
| 132 | protocolVersion: request.headers.get('mcp-protocol-version'),
|
| 133 | contentType: request.headers.get('content-type'),
|
| 134 | hasAuthorization: Boolean(request.headers.get('authorization')),
|
| 135 | response: (await response.clone().text()).slice(0, 1000),
|
| 136 | });
|
| 137 | }
|
| 138 | if (response.status !== 401 || request.headers.get('authorization')?.trim()) return response;
|
| 139 |
|
| 140 | // A missing credential is an authentication discovery challenge, not an invalid token.
|
| 141 | // Cursor's V2 MCP client treats `invalid_token` while auth is still unknown as a
|
| 142 | // transient connection failure instead of starting OAuth.
|
| 143 | const publicOrigin = new URL(request.url).origin;
|
| 144 | const headers = new Headers(response.headers);
|
| 145 | headers.set(
|
| 146 | 'www-authenticate',
|
| 147 | `Bearer resource_metadata="${new URL(resourceMetadataPath, publicOrigin).href}", scope="${DEFAULT_MCP_AUTH_SCOPES.join(' ')}"`,
|
| 148 | );
|
| 149 | headers.set('cache-control', 'no-store');
|
| 150 | headers.delete('content-length');
|
| 151 | headers.delete('content-type');
|
| 152 | return new Response(null, {
|
| 153 | status: response.status,
|
| 154 | statusText: response.statusText,
|
| 155 | headers,
|
| 156 | });
|
| 157 | }
|
| 158 |
|
| 159 | export { handler as GET, handler as POST };
|