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

Viewing files at commit 66ff6831b5d2Commit detailsView latest

api/mcp.ts 149 lines · 5.3 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
  ...(process.env.OPENCLASP_MCP_URL
109
    ? { resourceUrl: new URL(process.env.OPENCLASP_MCP_URL).origin }
110
    : {}),
111
});
112
113
async function handler(request: Request): Promise<Response> {
114
  const response = await authenticatedHandler(request);
115
  if (response.status >= 500) {
116
    console.error('[mcp.http] request failed', {
117
      status: response.status,
118
      method: request.method,
119
      protocolVersion: request.headers.get('mcp-protocol-version'),
120
      contentType: request.headers.get('content-type'),
121
      hasAuthorization: Boolean(request.headers.get('authorization')),
122
      response: (await response.clone().text()).slice(0, 1000),
123
    });
124
  }
125
  if (response.status !== 401 || request.headers.get('authorization')?.trim()) return response;
126
127
  // A missing credential is an authentication discovery challenge, not an invalid token.
128
  // Cursor's V2 MCP client treats `invalid_token` while auth is still unknown as a
129
  // transient connection failure instead of starting OAuth.
130
  const configuredResource = process.env.OPENCLASP_MCP_URL;
131
  const publicOrigin = configuredResource
132
    ? new URL(configuredResource).origin
133
    : new URL(request.url).origin;
134
  const headers = new Headers(response.headers);
135
  headers.set(
136
    'www-authenticate',
137
    `Bearer resource_metadata="${new URL(resourceMetadataPath, publicOrigin).href}", scope="mcp:access"`,
138
  );
139
  headers.set('cache-control', 'no-store');
140
  headers.delete('content-length');
141
  headers.delete('content-type');
142
  return new Response(null, {
143
    status: response.status,
144
    statusText: response.statusText,
145
    headers,
146
  });
147
}
148
149
export { handler as GET, handler as POST };