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

Viewing files at commit a9e4757bb766Commit detailsView latest

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