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

Viewing files at commit 1f74f1f0e3abCommit detailsView latest

api/mcp.ts 138 lines · 4.9 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
  },
51
);
52
53
async function verifyToken(_request: Request, bearerToken?: string): Promise<AuthInfo | undefined> {
54
  if (!bearerToken) return undefined;
55
  if (bearerToken.startsWith('oc_oat_')) {
56
    const authentication = await oauthStore().verifyAccessToken(bearerToken);
57
    if (!authentication || !authentication.scopes.includes('mcp:access'))
58
      throw new Error('OpenClasp OAuth token is invalid or missing the MCP scope');
59
    return {
60
      token: bearerToken,
61
      clientId: authentication.clientId,
62
      scopes: authentication.scopes,
63
      expiresAt: Math.floor(Date.parse(authentication.expiresAt) / 1000),
64
      extra: {
65
        operatorId: authentication.operatorId,
66
        credentialType: 'oauth_access_token',
67
      },
68
    };
69
  }
70
  if (bearerToken.startsWith('oc_at_')) {
71
    if (!repository) throw new Error('Agent access tokens are not configured');
72
    const authentication = await repository.verifyAgentAccessToken(bearerToken);
73
    if (!authentication.scopes.includes('mcp:access'))
74
      throw new Error('Agent access token is missing the MCP scope');
75
    return {
76
      token: bearerToken,
77
      clientId: authentication.clientId,
78
      scopes: authentication.scopes,
79
      expiresAt: Math.floor(Date.parse(authentication.expiresAt) / 1000),
80
      extra: {
81
        operatorId: authentication.operatorId,
82
        boundAgentId: authentication.agentId,
83
        credentialType: 'agent_access_token',
84
      },
85
    };
86
  }
87
  const authentication = await verifyAuth0Token(bearerToken, {
88
    dashboard: false,
89
    requiredScopes: ['mcp:access'],
90
  });
91
  return {
92
    token: bearerToken,
93
    clientId: authentication.clientId,
94
    scopes: authentication.scopes,
95
    ...(typeof authentication.payload.exp === 'number'
96
      ? { expiresAt: authentication.payload.exp }
97
      : {}),
98
    extra: { operatorId: authentication.payload.sub },
99
  };
100
}
101
102
const resourceMetadataPath = '/.well-known/oauth-protected-resource/mcp';
103
104
const authenticatedHandler = withMcpAuth(mcp, verifyToken, {
105
  required: true,
106
  resourceMetadataPath,
107
  ...(process.env.OPENCLASP_MCP_URL
108
    ? { resourceUrl: new URL(process.env.OPENCLASP_MCP_URL).origin }
109
    : {}),
110
});
111
112
async function handler(request: Request): Promise<Response> {
113
  const response = await authenticatedHandler(request);
114
  if (response.status !== 401 || request.headers.get('authorization')?.trim()) return response;
115
116
  // A missing credential is an authentication discovery challenge, not an invalid token.
117
  // Cursor's V2 MCP client treats `invalid_token` while auth is still unknown as a
118
  // transient connection failure instead of starting OAuth.
119
  const configuredResource = process.env.OPENCLASP_MCP_URL;
120
  const publicOrigin = configuredResource
121
    ? new URL(configuredResource).origin
122
    : new URL(request.url).origin;
123
  const headers = new Headers(response.headers);
124
  headers.set(
125
    'www-authenticate',
126
    `Bearer resource_metadata="${new URL(resourceMetadataPath, publicOrigin).href}", scope="mcp:access"`,
127
  );
128
  headers.set('cache-control', 'no-store');
129
  headers.delete('content-length');
130
  headers.delete('content-type');
131
  return new Response(null, {
132
    status: response.status,
133
    statusText: response.statusText,
134
    headers,
135
  });
136
}
137
138
export { handler as GET, handler as POST };