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

Viewing files at commit 7ef7c69958caCommit detailsView latest

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