@equilateral_ai/mindmeld 3.4.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * MindMeld MCP Streaming Handler — Lambda Function URL (RESPONSE_STREAM)
3
+ *
4
+ * Implements MCP Streamable HTTP transport (protocol version 2025-03-26):
5
+ * - POST: JSON-RPC messages, responds with JSON or SSE stream
6
+ * - GET: SSE handshake or OAuth discovery
7
+ * - DELETE: Session close
8
+ * - OPTIONS: CORS preflight
9
+ *
10
+ * Auth: OAuth 2.1 (Cognito JWT) OR X-MindMeld-Token / Bearer API token
11
+ *
12
+ * OAuth Discovery (RFC 9728):
13
+ * GET /.well-known/oauth-protected-resource → resource metadata
14
+ * 401 responses include WWW-Authenticate with resource_metadata URL
15
+ *
16
+ * Uses awslambda.streamifyResponse() for Lambda Function URL streaming.
17
+ * The `awslambda` global is provided by the Lambda Node.js runtime.
18
+ */
19
+
20
+ const { validateApiToken, handleJsonRpc, CORS_HEADERS } = require('./mindmeldMcpCore');
21
+ const crypto = require('crypto');
22
+
23
+ // OAuth / Cognito configuration
24
+ const COGNITO_ISSUER = 'https://cognito-idp.us-east-2.amazonaws.com/us-east-2_638OhwuV1';
25
+ const COGNITO_AUTH_DOMAIN = 'https://mindmeld-auth.auth.us-east-2.amazoncognito.com';
26
+ const MCP_OAUTH_CLIENT_ID = '5bjpug8up86so7k7ndttobc0qi';
27
+
28
+ /**
29
+ * Write an SSE event to the response stream
30
+ */
31
+ function writeSSE(stream, data, eventType) {
32
+ if (eventType) {
33
+ stream.write(`event: ${eventType}\n`);
34
+ }
35
+ stream.write(`data: ${JSON.stringify(data)}\n\n`);
36
+ }
37
+
38
+ /**
39
+ * Parse Lambda Function URL event (different format from API Gateway)
40
+ */
41
+ function parseRequest(event) {
42
+ const http = event.requestContext?.http || {};
43
+ return {
44
+ method: http.method || 'GET',
45
+ path: http.path || '/',
46
+ headers: event.headers || {},
47
+ body: event.body || '',
48
+ isBase64Encoded: event.isBase64Encoded || false,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Create a response stream with headers
54
+ */
55
+ function createStream(responseStream, statusCode, headers) {
56
+ return awslambda.HttpResponseStream.from(responseStream, {
57
+ statusCode,
58
+ headers: { ...CORS_HEADERS, ...headers },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Build the Function URL base from the event
64
+ */
65
+ function getBaseUrl(event) {
66
+ const host = event.headers?.host || '';
67
+ return `https://${host}`;
68
+ }
69
+
70
+ /**
71
+ * Return OAuth Protected Resource Metadata (RFC 9728)
72
+ * Points to ourselves as the authorization server so we can serve
73
+ * metadata that includes code_challenge_methods_supported (PKCE).
74
+ * Actual OAuth endpoints still point to Cognito.
75
+ */
76
+ function getResourceMetadata(baseUrl) {
77
+ return {
78
+ resource: baseUrl,
79
+ authorization_servers: [baseUrl],
80
+ scopes_supported: ['mcp/standards', 'openid', 'email', 'profile'],
81
+ bearer_methods_supported: ['header'],
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Return OAuth Authorization Server Metadata (RFC 8414)
87
+ * Wraps Cognito's endpoints with PKCE support declaration.
88
+ * Cognito supports PKCE but doesn't advertise it in OIDC metadata.
89
+ */
90
+ function getAuthServerMetadata(baseUrl) {
91
+ return {
92
+ issuer: baseUrl,
93
+ authorization_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/authorize`,
94
+ token_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/token`,
95
+ revocation_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/revoke`,
96
+ userinfo_endpoint: `${COGNITO_AUTH_DOMAIN}/oauth2/userInfo`,
97
+ jwks_uri: `${COGNITO_ISSUER}/.well-known/jwks.json`,
98
+ scopes_supported: ['mcp/standards', 'openid', 'email', 'profile'],
99
+ response_types_supported: ['code'],
100
+ grant_types_supported: ['authorization_code', 'refresh_token'],
101
+ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
102
+ code_challenge_methods_supported: ['S256'],
103
+ subject_types_supported: ['public'],
104
+ id_token_signing_alg_values_supported: ['RS256'],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Return 401 with WWW-Authenticate header per MCP OAuth spec
110
+ */
111
+ function write401(stream, baseUrl) {
112
+ stream.write(JSON.stringify({
113
+ error: 'unauthorized',
114
+ message: 'Authentication required. Use OAuth or provide X-MindMeld-Token header.',
115
+ }));
116
+ stream.end();
117
+ }
118
+
119
+ // Lambda Function URL streaming handler
120
+ const streamHandler = async (event, responseStream, context) => {
121
+ // CRITICAL: Don't wait for event loop to drain — the cached pg client
122
+ // keeps it alive forever, causing 900s timeouts on every request.
123
+ context.callbackWaitsForEmptyEventLoop = false;
124
+
125
+ const { method, headers, body, isBase64Encoded } = parseRequest(event);
126
+ const path = parseRequest(event).path;
127
+ const baseUrl = getBaseUrl(event);
128
+
129
+ // Request logging for debugging
130
+ const hasAuth = !!(headers['authorization'] || headers['x-mindmeld-token']);
131
+ console.log(`[MCP-Stream] ${method} ${path} auth=${hasAuth} accept=${headers['accept'] || 'none'}`);
132
+
133
+ // === OAuth Discovery: Protected Resource Metadata (RFC 9728) ===
134
+ if (method === 'GET' && path === '/.well-known/oauth-protected-resource') {
135
+ const stream = createStream(responseStream, 200, {
136
+ 'Content-Type': 'application/json',
137
+ 'Cache-Control': 'public, max-age=3600',
138
+ });
139
+ stream.write(JSON.stringify(getResourceMetadata(baseUrl)));
140
+ stream.end();
141
+ return;
142
+ }
143
+
144
+ // === OAuth Discovery: Authorization Server Metadata (RFC 8414) ===
145
+ if (method === 'GET' && path === '/.well-known/oauth-authorization-server') {
146
+ const stream = createStream(responseStream, 200, {
147
+ 'Content-Type': 'application/json',
148
+ 'Cache-Control': 'public, max-age=3600',
149
+ });
150
+ stream.write(JSON.stringify(getAuthServerMetadata(baseUrl)));
151
+ stream.end();
152
+ return;
153
+ }
154
+
155
+ // === OPTIONS: CORS preflight ===
156
+ if (method === 'OPTIONS') {
157
+ const stream = createStream(responseStream, 200, {
158
+ 'Access-Control-Max-Age': '86400',
159
+ });
160
+ stream.end();
161
+ return;
162
+ }
163
+
164
+ // === DELETE: Session close ===
165
+ if (method === 'DELETE') {
166
+ const stream = createStream(responseStream, 200, {
167
+ 'Content-Type': 'application/json',
168
+ });
169
+ stream.end();
170
+ return;
171
+ }
172
+
173
+ // WWW-Authenticate header for 401 responses
174
+ const wwwAuth = `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`;
175
+
176
+ // === GET: SSE handshake ===
177
+ if (method === 'GET') {
178
+ const authResult = await validateApiToken(headers);
179
+ if (authResult.error) {
180
+ const stream = createStream(responseStream, 401, {
181
+ 'Content-Type': 'application/json',
182
+ 'WWW-Authenticate': wwwAuth,
183
+ });
184
+ write401(stream, baseUrl);
185
+ return;
186
+ }
187
+
188
+ const sessionId = crypto.randomUUID();
189
+ const stream = createStream(responseStream, 200, {
190
+ 'Content-Type': 'text/event-stream',
191
+ 'Cache-Control': 'no-cache',
192
+ 'Connection': 'keep-alive',
193
+ 'Mcp-Session-Id': sessionId,
194
+ });
195
+
196
+ // Send endpoint event — legacy SSE clients expect this
197
+ stream.write(`event: endpoint\ndata: ${path}\n\n`);
198
+
199
+ // For Streamable HTTP clients probing via GET: close after endpoint event.
200
+ // The client will then POST to us for actual JSON-RPC messages.
201
+ stream.end();
202
+ return;
203
+ }
204
+
205
+ // === POST: Streamable HTTP JSON-RPC ===
206
+ if (method !== 'POST') {
207
+ const stream = createStream(responseStream, 405, {
208
+ 'Content-Type': 'application/json',
209
+ });
210
+ stream.write(JSON.stringify({ error: 'Method not allowed' }));
211
+ stream.end();
212
+ return;
213
+ }
214
+
215
+ // Auth
216
+ const authResult = await validateApiToken(headers);
217
+ if (authResult.error) {
218
+ console.log(`[MCP-Stream] POST auth failed: ${authResult.error} - ${authResult.message}`);
219
+ const stream = createStream(responseStream, 401, {
220
+ 'Content-Type': 'application/json',
221
+ 'WWW-Authenticate': wwwAuth,
222
+ });
223
+ write401(stream, baseUrl);
224
+ return;
225
+ }
226
+ console.log(`[MCP-Stream] POST auth OK: ${authResult.user.email}`);
227
+
228
+ // Parse body (Function URL may base64-encode it)
229
+ let parsedBody;
230
+ try {
231
+ const raw = isBase64Encoded ? Buffer.from(body, 'base64').toString() : body;
232
+ parsedBody = JSON.parse(raw);
233
+ } catch (e) {
234
+ const stream = createStream(responseStream, 400, {
235
+ 'Content-Type': 'application/json',
236
+ });
237
+ stream.write(JSON.stringify({
238
+ jsonrpc: '2.0',
239
+ error: { code: -32700, message: 'Parse error: invalid JSON' },
240
+ id: null,
241
+ }));
242
+ stream.end();
243
+ return;
244
+ }
245
+
246
+ // Session ID management
247
+ const sessionId = headers['mcp-session-id'] || crypto.randomUUID();
248
+
249
+ // Check if client wants SSE response
250
+ const acceptHeader = headers['accept'] || '';
251
+ const wantsSSE = acceptHeader.includes('text/event-stream');
252
+
253
+ if (wantsSSE) {
254
+ // === SSE streaming response ===
255
+ const stream = createStream(responseStream, 200, {
256
+ 'Content-Type': 'text/event-stream',
257
+ 'Cache-Control': 'no-cache',
258
+ 'Mcp-Session-Id': sessionId,
259
+ });
260
+
261
+ if (Array.isArray(parsedBody)) {
262
+ for (const msg of parsedBody) {
263
+ const response = await handleJsonRpc(msg, authResult.user);
264
+ if (response) {
265
+ writeSSE(stream, response, 'message');
266
+ }
267
+ }
268
+ } else {
269
+ const response = await handleJsonRpc(parsedBody, authResult.user);
270
+ if (response) {
271
+ writeSSE(stream, response, 'message');
272
+ }
273
+ }
274
+
275
+ stream.end();
276
+ } else {
277
+ // === Standard JSON response ===
278
+ const responseHeaders = {
279
+ 'Content-Type': 'application/json',
280
+ 'Mcp-Session-Id': sessionId,
281
+ };
282
+
283
+ if (Array.isArray(parsedBody)) {
284
+ const responses = [];
285
+ for (const msg of parsedBody) {
286
+ const response = await handleJsonRpc(msg, authResult.user);
287
+ if (response) responses.push(response);
288
+ }
289
+ const stream = createStream(responseStream,
290
+ responses.length > 0 ? 200 : 202, responseHeaders);
291
+ if (responses.length > 0) {
292
+ stream.write(JSON.stringify(responses));
293
+ }
294
+ stream.end();
295
+ } else {
296
+ const response = await handleJsonRpc(parsedBody, authResult.user);
297
+ if (!response) {
298
+ const stream = createStream(responseStream, 202, responseHeaders);
299
+ stream.end();
300
+ } else {
301
+ const stream = createStream(responseStream, 200, responseHeaders);
302
+ stream.write(JSON.stringify(response));
303
+ stream.end();
304
+ }
305
+ }
306
+ }
307
+ };
308
+
309
+ // awslambda is a global provided by the Lambda Node.js runtime.
310
+ // In local/test environments it won't exist — export the raw handler for testing.
311
+ if (typeof awslambda !== 'undefined') {
312
+ exports.handler = awslambda.streamifyResponse(streamHandler);
313
+ } else {
314
+ // Fallback for local testing: wrap as standard async handler
315
+ exports.handler = async (event) => {
316
+ let result = { statusCode: 200, headers: {}, body: '' };
317
+ const mockStream = {
318
+ _headers: {},
319
+ _statusCode: 200,
320
+ _chunks: [],
321
+ write(chunk) { this._chunks.push(chunk); },
322
+ end() {},
323
+ };
324
+ // Mock awslambda.HttpResponseStream.from
325
+ const originalFrom = mockStream;
326
+ const createMockStream = (_rs, meta) => {
327
+ mockStream._statusCode = meta.statusCode;
328
+ mockStream._headers = meta.headers;
329
+ return mockStream;
330
+ };
331
+ global.awslambda = {
332
+ HttpResponseStream: { from: createMockStream },
333
+ };
334
+ await streamHandler(event, mockStream, {});
335
+ delete global.awslambda;
336
+ return {
337
+ statusCode: mockStream._statusCode,
338
+ headers: mockStream._headers,
339
+ body: mockStream._chunks.join(''),
340
+ };
341
+ };
342
+ }
@@ -6,7 +6,7 @@
6
6
  * Auth: Cognito JWT required
7
7
  */
8
8
 
9
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
10
10
 
11
11
  async function getDiscoveries({ queryStringParameters, requestContext }) {
12
12
  try {
@@ -23,13 +23,9 @@ async function getDiscoveries({ queryStringParameters, requestContext }) {
23
23
  return createErrorResponse(400, 'project_id is required');
24
24
  }
25
25
 
26
- // Verify user has access to project
27
- const accessResult = await executeQuery(`
28
- SELECT role FROM rapport.project_collaborators
29
- WHERE project_id = $1 AND email_address = $2
30
- `, [projectId, email]);
31
-
32
- if (accessResult.rowCount === 0) {
26
+ // Verify user has access to project (collaborator or company member)
27
+ const projectAccess = await verifyProjectAccess(projectId, email);
28
+ if (!projectAccess) {
33
29
  return createErrorResponse(403, 'Access denied to project');
34
30
  }
35
31
 
@@ -6,7 +6,7 @@
6
6
  * Auth: Cognito JWT required
7
7
  */
8
8
 
9
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
10
10
 
11
11
  async function getProjectStandards({ queryStringParameters, pathParameters, requestContext }) {
12
12
  try {
@@ -22,19 +22,13 @@ async function getProjectStandards({ queryStringParameters, pathParameters, requ
22
22
  return createErrorResponse(400, 'projectId is required');
23
23
  }
24
24
 
25
- // Verify user has access to project
26
- const accessResult = await executeQuery(`
27
- SELECT pc.role, p.project_name
28
- FROM rapport.project_collaborators pc
29
- JOIN rapport.projects p ON pc.project_id = p.project_id
30
- WHERE pc.project_id = $1 AND pc.email_address = $2
31
- `, [projectId, email]);
32
-
33
- if (accessResult.rowCount === 0) {
25
+ // Verify user has access to project (collaborator or company member)
26
+ const projectAccess = await verifyProjectAccess(projectId, email);
27
+ if (!projectAccess) {
34
28
  return createErrorResponse(403, 'Access denied to project');
35
29
  }
36
30
 
37
- const projectName = accessResult.rows[0].project_name;
31
+ const projectName = projectAccess.project_name;
38
32
 
39
33
  // Get project standards preferences
40
34
  const prefsResult = await executeQuery(`
@@ -7,7 +7,7 @@
7
7
  * Auth: Cognito JWT required
8
8
  */
9
9
 
10
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
10
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectRole } = require('./helpers');
11
11
 
12
12
  async function updateProjectStandards({ body, pathParameters, requestContext }) {
13
13
  try {
@@ -27,21 +27,14 @@ async function updateProjectStandards({ body, pathParameters, requestContext })
27
27
  enabled_categories,
28
28
  standard_overrides,
29
29
  critical_overrides,
30
- catalog_id = 'equilateral-v1'
30
+ catalog_id = 'equilateral-v1',
31
+ standard_id,
32
+ load_bearing
31
33
  } = body || {};
32
34
 
33
- // Verify user has admin access to project
34
- const accessResult = await executeQuery(`
35
- SELECT role FROM rapport.project_collaborators
36
- WHERE project_id = $1 AND email_address = $2
37
- `, [projectId, email]);
38
-
39
- if (accessResult.rowCount === 0) {
40
- return createErrorResponse(403, 'Access denied to project');
41
- }
42
-
43
- const role = accessResult.rows[0].role;
44
- if (role !== 'owner' && role !== 'admin') {
35
+ // Verify user has admin access to project (project owner/admin or company admin)
36
+ const projectAccess = await verifyProjectRole(projectId, email, ['owner', 'admin']);
37
+ if (!projectAccess) {
45
38
  return createErrorResponse(403, 'Admin access required to modify standards preferences');
46
39
  }
47
40
 
@@ -70,6 +63,18 @@ async function updateProjectStandards({ body, pathParameters, requestContext })
70
63
  }
71
64
  }
72
65
 
66
+ // Update load_bearing flag on a specific standard if provided
67
+ if (standard_id && typeof load_bearing === 'boolean') {
68
+ await executeQuery(`
69
+ UPDATE rapport.standards_patterns
70
+ SET load_bearing = $1
71
+ WHERE pattern_id = $2
72
+ AND (company_id IS NULL OR company_id = (
73
+ SELECT company_id FROM rapport.user_entitlements WHERE email_address = $3 LIMIT 1
74
+ ))
75
+ `, [load_bearing, standard_id, email]);
76
+ }
77
+
73
78
  // Upsert project standards
74
79
  const result = await executeQuery(`
75
80
  INSERT INTO rapport.project_standards (
@@ -10,7 +10,7 @@
10
10
  * parsed YAML-compatible standards ready for storage or review.
11
11
  */
12
12
 
13
- const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
13
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, verifyProjectAccess } = require('./helpers');
14
14
 
15
15
  const { parseAdr } = require('./core/parsers/adrParser');
16
16
  const { parseEslint } = require('./core/parsers/eslintParser');
@@ -49,13 +49,9 @@ async function parseUploadStandards({ body, requestContext }) {
49
49
  });
50
50
  }
51
51
 
52
- // Verify user has access to the project
53
- const accessResult = await executeQuery(`
54
- SELECT role FROM rapport.project_collaborators
55
- WHERE project_id = $1 AND email_address = $2
56
- `, [project_id, email]);
57
-
58
- if (accessResult.rowCount === 0) {
52
+ // Verify user has access to the project (collaborator or company member)
53
+ const projectAccess = await verifyProjectAccess(project_id, email);
54
+ if (!projectAccess) {
59
55
  return createErrorResponse(403, 'Access denied to project');
60
56
  }
61
57