@rockcarver/frodo-cli 4.0.0-50 → 4.0.0-51

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,429 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+
9
+ function parseArgs(argv) {
10
+ const args = {
11
+ realm: '/alpha',
12
+ mcpConfig: '/Users/volker.scheuber/Library/Application Support/Code/User/mcp.json',
13
+ output: 'docs/mcp-oauth2-mayact-update-report.json',
14
+ baselineClientId: undefined,
15
+ };
16
+
17
+ for (let i = 2; i < argv.length; i += 1) {
18
+ const token = argv[i];
19
+ if (token === '--realm') {
20
+ args.realm = argv[i + 1] || args.realm;
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (token === '--mcp-config') {
25
+ args.mcpConfig = argv[i + 1] || args.mcpConfig;
26
+ i += 1;
27
+ continue;
28
+ }
29
+ if (token === '--output') {
30
+ args.output = argv[i + 1] || args.output;
31
+ i += 1;
32
+ continue;
33
+ }
34
+ if (token === '--baseline-client-id') {
35
+ args.baselineClientId = argv[i + 1] || args.baselineClientId;
36
+ i += 1;
37
+ continue;
38
+ }
39
+ }
40
+
41
+ return args;
42
+ }
43
+
44
+ function loadServerConfig(configPath) {
45
+ const raw = fs.readFileSync(configPath, 'utf8');
46
+ const json = JSON.parse(raw);
47
+ const server = json?.servers?.frodo;
48
+ if (!server?.command) {
49
+ throw new Error('Could not resolve servers.frodo.command from MCP config.');
50
+ }
51
+ return server;
52
+ }
53
+
54
+ async function connectClient(server) {
55
+ const transport = new StdioClientTransport({
56
+ command: server.command,
57
+ args: server.args,
58
+ env: server.env,
59
+ cwd: '/Users/volker.scheuber/Documents/Projects/frodo-cli',
60
+ stderr: 'pipe',
61
+ });
62
+
63
+ if (transport.stderr) {
64
+ transport.stderr.on('data', () => {
65
+ // Drain stderr without polluting test output.
66
+ });
67
+ }
68
+
69
+ const client = new Client(
70
+ { name: 'frodo-oauth2-mayact-update-test', version: '1.0.0' },
71
+ { capabilities: {} }
72
+ );
73
+
74
+ await client.connect(transport);
75
+ return { client, transport };
76
+ }
77
+
78
+ function getTextContent(result) {
79
+ if (!Array.isArray(result?.content)) {
80
+ return '';
81
+ }
82
+
83
+ return result.content
84
+ .filter((item) => item?.type === 'text' && typeof item?.text === 'string')
85
+ .map((item) => item.text)
86
+ .join(' ');
87
+ }
88
+
89
+ function parsePayload(result) {
90
+ const text = getTextContent(result);
91
+ if (!text) {
92
+ return null;
93
+ }
94
+ try {
95
+ return JSON.parse(text);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function getArrayData(payload) {
102
+ const data = payload?.data;
103
+ if (Array.isArray(data)) {
104
+ return data;
105
+ }
106
+ if (data && typeof data === 'object' && Array.isArray(data.result)) {
107
+ return data.result;
108
+ }
109
+ return [];
110
+ }
111
+
112
+ async function callTool(client, toolName, args, extra = {}) {
113
+ try {
114
+ const result = await client.callTool({ name: toolName, arguments: args });
115
+ const text = getTextContent(result);
116
+ const payload = parsePayload(result);
117
+ return {
118
+ toolName,
119
+ args,
120
+ isError: !!result?.isError,
121
+ message: result?.isError ? text : '',
122
+ payload,
123
+ ...extra,
124
+ };
125
+ } catch (error) {
126
+ return {
127
+ toolName,
128
+ args,
129
+ isError: true,
130
+ message: error instanceof Error ? error.message : String(error),
131
+ payload: null,
132
+ ...extra,
133
+ };
134
+ }
135
+ }
136
+
137
+ function buildMayActScriptSource() {
138
+ // Fictional test values only. These are not real subjects or client ids.
139
+ return [
140
+ '(function () {',
141
+ ' var mayAct = {',
142
+ " subject: 'fictional-subject-001',",
143
+ " client_ids: ['fictional-client-alpha', 'fictional-client-beta']",
144
+ ' };',
145
+ ' return mayAct;',
146
+ '}());',
147
+ ].join('\n');
148
+ }
149
+
150
+ function ensureGrantTypeTokenExchange(clientData) {
151
+ const value = clientData?.advancedOAuth2ClientConfig?.grantTypes?.value;
152
+ if (!Array.isArray(value)) {
153
+ return;
154
+ }
155
+ if (!value.includes('urn:ietf:params:oauth:grant-type:token-exchange')) {
156
+ value.push('urn:ietf:params:oauth:grant-type:token-exchange');
157
+ }
158
+ }
159
+
160
+ function setTokenExchangeAuthLevel(clientData, level) {
161
+ const tx = clientData?.advancedOAuth2ClientConfig?.tokenExchangeAuthLevel;
162
+ if (tx && typeof tx === 'object' && 'value' in tx) {
163
+ tx.value = level;
164
+ return;
165
+ }
166
+ if (clientData?.advancedOAuth2ClientConfig) {
167
+ clientData.advancedOAuth2ClientConfig.tokenExchangeAuthLevel = level;
168
+ }
169
+ }
170
+
171
+ function setClientName(clientData, clientId) {
172
+ const wrapper = clientData?.coreOAuth2ClientConfig?.clientName;
173
+ if (wrapper && typeof wrapper === 'object' && Array.isArray(wrapper.value)) {
174
+ wrapper.value = [clientId];
175
+ return;
176
+ }
177
+ if (clientData?.coreOAuth2ClientConfig) {
178
+ clientData.coreOAuth2ClientConfig.clientName = [clientId];
179
+ }
180
+ }
181
+
182
+ function assert(condition, message) {
183
+ if (!condition) {
184
+ throw new Error(message);
185
+ }
186
+ }
187
+
188
+ async function run() {
189
+ const args = parseArgs(process.argv);
190
+ const server = loadServerConfig(args.mcpConfig);
191
+ const { client, transport } = await connectClient(server);
192
+
193
+ const stamp = Date.now();
194
+ const scriptId = `mcp-mayact-script-${stamp}`;
195
+ const clientId = `mcp-mayact-client-${stamp}`;
196
+
197
+ const report = {
198
+ generatedAt: new Date().toISOString(),
199
+ status: 'unknown',
200
+ summary: '',
201
+ input: {
202
+ realm: args.realm,
203
+ baselineClientId: args.baselineClientId || null,
204
+ },
205
+ resources: {
206
+ scriptId,
207
+ clientId,
208
+ baselineClientId: null,
209
+ },
210
+ attempts: [],
211
+ verification: {
212
+ scriptCreated: false,
213
+ clientCreated: false,
214
+ clientUpdated: false,
215
+ mayActScriptAssigned: false,
216
+ tokenExchangeEnabled: false,
217
+ tokenExchangeAuthLevel: null,
218
+ grantTypes: [],
219
+ },
220
+ };
221
+
222
+ try {
223
+ const scriptSource = buildMayActScriptSource();
224
+ const scriptData = {
225
+ _id: scriptId,
226
+ name: scriptId,
227
+ context: 'OAUTH2_MAY_ACT',
228
+ language: 'JAVASCRIPT',
229
+ script: Buffer.from(scriptSource, 'utf8').toString('base64'),
230
+ };
231
+
232
+ const createScriptAttempt = await callTool(
233
+ client,
234
+ 'frodo_create',
235
+ {
236
+ domain: 'script',
237
+ objectType: 'Script',
238
+ realm: args.realm,
239
+ namedArgs: {
240
+ scriptId,
241
+ scriptData,
242
+ },
243
+ },
244
+ { step: 'create-script-via-create' }
245
+ );
246
+ report.attempts.push(createScriptAttempt);
247
+ assert(!createScriptAttempt.isError, `Script create/upsert failed: ${createScriptAttempt.message}`);
248
+
249
+ const readScriptAttempt = await callTool(
250
+ client,
251
+ 'frodo_read',
252
+ {
253
+ domain: 'script',
254
+ objectType: 'Script',
255
+ realm: args.realm,
256
+ namedArgs: { scriptId },
257
+ },
258
+ { step: 'verify-script' }
259
+ );
260
+ report.attempts.push(readScriptAttempt);
261
+ assert(!readScriptAttempt.isError, `Script read-back failed: ${readScriptAttempt.message}`);
262
+ report.verification.scriptCreated = true;
263
+
264
+ let baselineClientId = args.baselineClientId;
265
+ if (!baselineClientId) {
266
+ const listAttempt = await callTool(
267
+ client,
268
+ 'frodo_list',
269
+ {
270
+ domain: 'oauth2oidc',
271
+ objectType: 'OAuth2Client',
272
+ realm: args.realm,
273
+ pageSize: 1,
274
+ },
275
+ { step: 'find-baseline-client' }
276
+ );
277
+ report.attempts.push(listAttempt);
278
+ assert(!listAttempt.isError, `OAuth2 client list failed: ${listAttempt.message}`);
279
+ const listData = getArrayData(listAttempt.payload);
280
+ baselineClientId = listData[0]?._id;
281
+ assert(typeof baselineClientId === 'string' && baselineClientId.length > 0, 'No baseline OAuth2 client available to clone.');
282
+ }
283
+
284
+ report.resources.baselineClientId = baselineClientId;
285
+
286
+ const readBaselineAttempt = await callTool(
287
+ client,
288
+ 'frodo_read',
289
+ {
290
+ domain: 'oauth2oidc',
291
+ objectType: 'OAuth2Client',
292
+ realm: args.realm,
293
+ positionalArgs: [baselineClientId],
294
+ },
295
+ { step: 'read-baseline-client' }
296
+ );
297
+ report.attempts.push(readBaselineAttempt);
298
+ assert(!readBaselineAttempt.isError, `Baseline OAuth2 client read failed: ${readBaselineAttempt.message}`);
299
+
300
+ const baselineData = readBaselineAttempt.payload?.data;
301
+ assert(baselineData && typeof baselineData === 'object', 'Baseline OAuth2 client payload missing data object.');
302
+
303
+ const newClientData = JSON.parse(JSON.stringify(baselineData));
304
+ delete newClientData._rev;
305
+ newClientData._id = clientId;
306
+ setClientName(newClientData, clientId);
307
+
308
+ const createClientAttempt = await callTool(
309
+ client,
310
+ 'frodo_create',
311
+ {
312
+ domain: 'oauth2oidc',
313
+ objectType: 'OAuth2Client',
314
+ realm: args.realm,
315
+ namedArgs: {
316
+ clientId,
317
+ clientData: newClientData,
318
+ },
319
+ },
320
+ { step: 'create-client' }
321
+ );
322
+ report.attempts.push(createClientAttempt);
323
+ assert(!createClientAttempt.isError, `OAuth2 client create failed: ${createClientAttempt.message}`);
324
+ report.verification.clientCreated = true;
325
+
326
+ const readNewClientAttempt = await callTool(
327
+ client,
328
+ 'frodo_read',
329
+ {
330
+ domain: 'oauth2oidc',
331
+ objectType: 'OAuth2Client',
332
+ realm: args.realm,
333
+ positionalArgs: [clientId],
334
+ },
335
+ { step: 'read-new-client-before-update' }
336
+ );
337
+ report.attempts.push(readNewClientAttempt);
338
+ assert(!readNewClientAttempt.isError, `New OAuth2 client read failed: ${readNewClientAttempt.message}`);
339
+
340
+ const updateData = readNewClientAttempt.payload?.data;
341
+ assert(updateData && typeof updateData === 'object', 'New OAuth2 client update payload missing data object.');
342
+
343
+ ensureGrantTypeTokenExchange(updateData);
344
+ setTokenExchangeAuthLevel(updateData, 1);
345
+ updateData.overrideOAuth2ClientConfig.oidcMayActScript = scriptId;
346
+
347
+ const updateClientAttempt = await callTool(
348
+ client,
349
+ 'frodo_update',
350
+ {
351
+ domain: 'oauth2oidc',
352
+ objectType: 'OAuth2Client',
353
+ realm: args.realm,
354
+ namedArgs: {
355
+ clientId,
356
+ clientData: updateData,
357
+ },
358
+ },
359
+ { step: 'update-client-may-act-token-exchange' }
360
+ );
361
+ report.attempts.push(updateClientAttempt);
362
+ assert(!updateClientAttempt.isError, `OAuth2 client update failed: ${updateClientAttempt.message}`);
363
+ report.verification.clientUpdated = true;
364
+
365
+ const verifyClientAttempt = await callTool(
366
+ client,
367
+ 'frodo_read',
368
+ {
369
+ domain: 'oauth2oidc',
370
+ objectType: 'OAuth2Client',
371
+ realm: args.realm,
372
+ positionalArgs: [clientId],
373
+ },
374
+ { step: 'verify-client-after-update' }
375
+ );
376
+ report.attempts.push(verifyClientAttempt);
377
+ assert(!verifyClientAttempt.isError, `Final OAuth2 client read failed: ${verifyClientAttempt.message}`);
378
+
379
+ const verified = verifyClientAttempt.payload?.data || {};
380
+ const mayActScript = verified?.overrideOAuth2ClientConfig?.oidcMayActScript;
381
+ const grantTypes = verified?.advancedOAuth2ClientConfig?.grantTypes?.value || [];
382
+ const txLevel = verified?.advancedOAuth2ClientConfig?.tokenExchangeAuthLevel?.value;
383
+
384
+ report.verification.mayActScriptAssigned = mayActScript === scriptId;
385
+ report.verification.tokenExchangeEnabled = Array.isArray(grantTypes)
386
+ ? grantTypes.includes('urn:ietf:params:oauth:grant-type:token-exchange')
387
+ : false;
388
+ report.verification.tokenExchangeAuthLevel = txLevel;
389
+ report.verification.grantTypes = Array.isArray(grantTypes) ? grantTypes : [];
390
+
391
+ assert(report.verification.mayActScriptAssigned, 'May-act script id was not applied to the new client.');
392
+ assert(report.verification.tokenExchangeEnabled, 'Token exchange grant type was not enabled on the new client.');
393
+
394
+ report.status = 'passed';
395
+ report.summary = 'Created a distinct OAuth2 client, created a distinct OAUTH2_MAY_ACT script, and updated the new client for token exchange + oidcMayActScript using MCP only.';
396
+ } catch (error) {
397
+ report.status = 'failed';
398
+ report.summary = error instanceof Error ? error.message : String(error);
399
+ } finally {
400
+ const outputPath = path.isAbsolute(args.output)
401
+ ? args.output
402
+ : path.join('/Users/volker.scheuber/Documents/Projects/frodo-cli', args.output);
403
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
404
+ fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
405
+ console.log(JSON.stringify(report, null, 2));
406
+
407
+ try {
408
+ await client.close();
409
+ } catch {
410
+ // Ignore shutdown errors.
411
+ }
412
+ try {
413
+ if (transport.close) {
414
+ await transport.close();
415
+ }
416
+ } catch {
417
+ // Ignore transport shutdown errors.
418
+ }
419
+
420
+ if (report.status === 'failed') {
421
+ process.exitCode = 1;
422
+ }
423
+ }
424
+ }
425
+
426
+ run().catch((error) => {
427
+ console.error(error instanceof Error ? error.stack : String(error));
428
+ process.exit(1);
429
+ });