The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 4e1e50d12753Commit detailsView latest

api/mcp.ts 159 lines · 5.6 KB · typescript
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 '../apps/api/src/auth0.js';
10
import { oauthStore } from '../apps/api/src/oauth-store.js';
11
import {
12
  DEFAULT_MCP_AUTH_SCOPES,
13
  assertScopes,
14
  requiredMcpRequestScopes,
15
} from '../apps/api/src/access-control.js';
16
import { guardRequest } from '../apps/api/src/request-security.js';
17
import { assertProductionConfiguration } from '../apps/api/src/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 };