fraim-framework 2.0.74 → 2.0.76
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.
- package/README.md +12 -105
- package/bin/fraim-mcp.js +1 -1
- package/dist/src/cli/commands/add-ide.js +15 -17
- package/dist/src/cli/commands/init-project.js +11 -9
- package/dist/src/cli/commands/install.js +86 -0
- package/dist/src/cli/commands/override.js +11 -1
- package/dist/src/cli/commands/sync.js +2 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +15 -19
- package/dist/src/cli/setup/codex-local-config.js +37 -0
- package/dist/src/cli/setup/mcp-config-generator.js +94 -11
- package/dist/src/cli/utils/remote-sync.js +22 -2
- package/dist/src/core/config-loader.js +4 -10
- package/dist/src/core/types.js +2 -19
- package/dist/src/core/utils/include-resolver.js +47 -0
- package/dist/src/local-mcp-server/stdio-server.js +624 -203
- package/package.json +116 -106
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* 1. Accepts MCP requests via stdin/stdout
|
|
8
8
|
* 2. Proxies to remote FRAIM server
|
|
9
9
|
* 3. Performs template substitution:
|
|
10
|
-
* -
|
|
11
|
-
* - Platform-specific actions: {{get_issue}}, {{create_pr}}, etc.
|
|
10
|
+
* - Proxy config variables: {{proxy.config.path.to.value}}
|
|
11
|
+
* - Platform-specific actions: {{proxy.action.get_issue}}, {{proxy.action.create_pr}}, etc.
|
|
12
12
|
* 4. Automatically detects and injects machine/repo info for fraim_connect
|
|
13
|
-
* 5. Substitutes {{delivery.*}} templates based on user's workingStyle
|
|
13
|
+
* 5. Substitutes {{proxy.delivery.*}} templates based on user's workingStyle
|
|
14
14
|
* (PR or Conversation from ~/.fraim/config.json). Delivery phases live
|
|
15
15
|
* server-side in the workflow; the proxy just fills in mode-specific content.
|
|
16
16
|
*/
|
|
@@ -35,39 +35,59 @@ class FraimTemplateEngine {
|
|
|
35
35
|
constructor(opts) {
|
|
36
36
|
this.deliveryTemplatesCache = null;
|
|
37
37
|
this.providerTemplatesCache = {};
|
|
38
|
+
this.deliveryTemplatesLoadAttempted = false;
|
|
39
|
+
this.providerTemplatesLoadAttempted = new Set();
|
|
38
40
|
this.config = opts.config;
|
|
39
41
|
this.repoInfo = opts.repoInfo;
|
|
40
42
|
this.workingStyle = opts.workingStyle;
|
|
41
43
|
this.projectRoot = opts.projectRoot;
|
|
42
44
|
this.logFn = opts.logFn || (() => { });
|
|
43
45
|
}
|
|
46
|
+
setDeliveryTemplates(templates) {
|
|
47
|
+
this.deliveryTemplatesCache = templates;
|
|
48
|
+
this.deliveryTemplatesLoadAttempted = true;
|
|
49
|
+
}
|
|
50
|
+
setProviderTemplates(provider, templates) {
|
|
51
|
+
this.providerTemplatesCache[provider] = templates;
|
|
52
|
+
this.providerTemplatesLoadAttempted.add(provider);
|
|
53
|
+
}
|
|
54
|
+
hasDeliveryTemplates() {
|
|
55
|
+
return this.deliveryTemplatesCache !== null;
|
|
56
|
+
}
|
|
57
|
+
hasProviderTemplates(provider) {
|
|
58
|
+
return !!this.providerTemplatesCache[provider];
|
|
59
|
+
}
|
|
60
|
+
setRepoInfo(repoInfo) {
|
|
61
|
+
this.repoInfo = repoInfo;
|
|
62
|
+
}
|
|
63
|
+
setConfig(config) {
|
|
64
|
+
this.config = config;
|
|
65
|
+
}
|
|
44
66
|
substituteTemplates(content) {
|
|
45
67
|
let result = content;
|
|
46
|
-
// First, substitute config variables with fallback support
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
// First, substitute config variables with fallback support.
|
|
69
|
+
// Fallbacks must work even when local config is unavailable.
|
|
70
|
+
result = result.replace(/\{\{proxy\.config\.([^}|]+)(?:\s*\|\s*"([^"]+)")?\}\}/g, (match, path, fallback) => {
|
|
71
|
+
try {
|
|
72
|
+
if (this.config) {
|
|
50
73
|
const value = (0, object_utils_1.getNestedValue)(this.config, path.trim());
|
|
51
74
|
if (value !== undefined) {
|
|
52
75
|
return typeof value === 'object'
|
|
53
76
|
? JSON.stringify(value)
|
|
54
77
|
: String(value);
|
|
55
78
|
}
|
|
56
|
-
if (fallback !== undefined) {
|
|
57
|
-
return fallback;
|
|
58
|
-
}
|
|
59
|
-
return match;
|
|
60
79
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
return fallback !== undefined ? fallback : match;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return fallback !== undefined ? fallback : match;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// Second, substitute {{proxy.delivery.*}} templates
|
|
67
87
|
const deliveryValues = this.loadDeliveryTemplates();
|
|
68
88
|
if (deliveryValues) {
|
|
69
|
-
result = result.replace(/\{\{delivery\.([^}]+)\}\}/g, (match, key) => {
|
|
70
|
-
const value = deliveryValues[`delivery.${key.trim()}`];
|
|
89
|
+
result = result.replace(/\{\{proxy\.delivery\.([^}]+)\}\}/g, (match, key) => {
|
|
90
|
+
const value = deliveryValues[`proxy.delivery.${key.trim()}`];
|
|
71
91
|
return value !== undefined ? value : match;
|
|
72
92
|
});
|
|
73
93
|
}
|
|
@@ -78,44 +98,12 @@ class FraimTemplateEngine {
|
|
|
78
98
|
loadDeliveryTemplates() {
|
|
79
99
|
if (this.deliveryTemplatesCache)
|
|
80
100
|
return this.deliveryTemplatesCache;
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
let content = null;
|
|
84
|
-
// Try framework installation directory first (relative to this file)
|
|
85
|
-
// This file is in dist/src/local-mcp-server/, so go up to framework root
|
|
86
|
-
const frameworkRoot = (0, path_1.join)(__dirname, '..', '..', '..');
|
|
87
|
-
const frameworkPath = (0, path_1.join)(frameworkRoot, 'registry', 'providers', filename);
|
|
88
|
-
if ((0, fs_1.existsSync)(frameworkPath)) {
|
|
89
|
-
content = (0, fs_1.readFileSync)(frameworkPath, 'utf-8');
|
|
90
|
-
this.logFn(`✅ Loaded delivery templates from framework: ${frameworkPath}`);
|
|
91
|
-
}
|
|
92
|
-
// Fallback: try node_modules if not found in framework root
|
|
93
|
-
if (!content) {
|
|
94
|
-
const nodeModulesPath = (0, path_1.join)(process.cwd(), 'node_modules', '@fraim', 'framework', 'registry', 'providers', filename);
|
|
95
|
-
if ((0, fs_1.existsSync)(nodeModulesPath)) {
|
|
96
|
-
content = (0, fs_1.readFileSync)(nodeModulesPath, 'utf-8');
|
|
97
|
-
this.logFn(`✅ Loaded delivery templates from node_modules: ${nodeModulesPath}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Last resort: try project root (for custom overrides)
|
|
101
|
-
if (!content && this.projectRoot) {
|
|
102
|
-
const deliveryPath = (0, path_1.join)(this.projectRoot, 'registry', 'providers', filename);
|
|
103
|
-
if ((0, fs_1.existsSync)(deliveryPath)) {
|
|
104
|
-
content = (0, fs_1.readFileSync)(deliveryPath, 'utf-8');
|
|
105
|
-
this.logFn(`✅ Loaded delivery templates from project: ${deliveryPath}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (content) {
|
|
109
|
-
this.deliveryTemplatesCache = JSON.parse(content);
|
|
110
|
-
return this.deliveryTemplatesCache;
|
|
111
|
-
}
|
|
112
|
-
this.logFn(`⚠️ Could not find delivery templates: ${filename}`);
|
|
101
|
+
if (this.deliveryTemplatesLoadAttempted)
|
|
113
102
|
return null;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
103
|
+
this.deliveryTemplatesLoadAttempted = true;
|
|
104
|
+
// Server-authoritative mode: runtime templates are hydrated via cache + remote fetch
|
|
105
|
+
// in FraimLocalMCPServer. Do not read framework/project registry files directly.
|
|
106
|
+
return null;
|
|
119
107
|
}
|
|
120
108
|
substitutePlatformActions(content) {
|
|
121
109
|
const provider = (0, provider_utils_1.detectProvider)(this.repoInfo?.url);
|
|
@@ -124,7 +112,8 @@ class FraimTemplateEngine {
|
|
|
124
112
|
return content;
|
|
125
113
|
let result = content;
|
|
126
114
|
for (const [action, template] of Object.entries(templates)) {
|
|
127
|
-
const
|
|
115
|
+
const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
const regex = new RegExp(`\\{\\{${FraimTemplateEngine.PROXY_ACTION_PREFIX}${escapedAction}\\}\\}`, 'g');
|
|
128
117
|
const renderedTemplate = this.renderActionTemplate(template);
|
|
129
118
|
result = result.replace(regex, renderedTemplate);
|
|
130
119
|
}
|
|
@@ -133,39 +122,12 @@ class FraimTemplateEngine {
|
|
|
133
122
|
loadProviderTemplates(provider) {
|
|
134
123
|
if (this.providerTemplatesCache[provider])
|
|
135
124
|
return this.providerTemplatesCache[provider];
|
|
136
|
-
|
|
137
|
-
let content = null;
|
|
138
|
-
// Try framework installation directory first (relative to this file)
|
|
139
|
-
const frameworkRoot = (0, path_1.join)(__dirname, '..', '..', '..');
|
|
140
|
-
const frameworkPath = (0, path_1.join)(frameworkRoot, 'registry', 'providers', `${provider}.json`);
|
|
141
|
-
if ((0, fs_1.existsSync)(frameworkPath)) {
|
|
142
|
-
content = (0, fs_1.readFileSync)(frameworkPath, 'utf-8');
|
|
143
|
-
}
|
|
144
|
-
// Fallback: try node_modules
|
|
145
|
-
if (!content) {
|
|
146
|
-
const nodeModulesPath = (0, path_1.join)(process.cwd(), 'node_modules', '@fraim', 'framework', 'registry', 'providers', `${provider}.json`);
|
|
147
|
-
if ((0, fs_1.existsSync)(nodeModulesPath)) {
|
|
148
|
-
content = (0, fs_1.readFileSync)(nodeModulesPath, 'utf-8');
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
// Last resort: try project root (for custom overrides)
|
|
152
|
-
if (!content && this.projectRoot) {
|
|
153
|
-
const providerPath = (0, path_1.join)(this.projectRoot, 'registry', 'providers', `${provider}.json`);
|
|
154
|
-
if ((0, fs_1.existsSync)(providerPath)) {
|
|
155
|
-
content = (0, fs_1.readFileSync)(providerPath, 'utf-8');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (content) {
|
|
159
|
-
const templates = JSON.parse(content);
|
|
160
|
-
this.providerTemplatesCache[provider] = templates;
|
|
161
|
-
return templates;
|
|
162
|
-
}
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
this.logFn(`⚠️ Failed to load provider templates: ${error.message}`);
|
|
125
|
+
if (this.providerTemplatesLoadAttempted.has(provider))
|
|
167
126
|
return null;
|
|
168
|
-
|
|
127
|
+
this.providerTemplatesLoadAttempted.add(provider);
|
|
128
|
+
// Server-authoritative mode: runtime templates are hydrated via cache + remote fetch
|
|
129
|
+
// in FraimLocalMCPServer. Do not read framework/project registry files directly.
|
|
130
|
+
return null;
|
|
169
131
|
}
|
|
170
132
|
renderActionTemplate(template) {
|
|
171
133
|
if (!this.repoInfo && !this.config?.repository) {
|
|
@@ -173,8 +135,8 @@ class FraimTemplateEngine {
|
|
|
173
135
|
}
|
|
174
136
|
return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
|
|
175
137
|
const trimmedPath = path.trim();
|
|
176
|
-
if (trimmedPath.startsWith('repository.')) {
|
|
177
|
-
const repoPath = trimmedPath.substring('repository.'.length);
|
|
138
|
+
if (trimmedPath.startsWith('proxy.repository.')) {
|
|
139
|
+
const repoPath = trimmedPath.substring('proxy.repository.'.length);
|
|
178
140
|
if (this.repoInfo) {
|
|
179
141
|
const value = (0, object_utils_1.getNestedValue)(this.repoInfo, repoPath);
|
|
180
142
|
if (value !== undefined)
|
|
@@ -191,6 +153,7 @@ class FraimTemplateEngine {
|
|
|
191
153
|
}
|
|
192
154
|
}
|
|
193
155
|
exports.FraimTemplateEngine = FraimTemplateEngine;
|
|
156
|
+
FraimTemplateEngine.PROXY_ACTION_PREFIX = 'proxy.action.';
|
|
194
157
|
class FraimLocalMCPServer {
|
|
195
158
|
constructor() {
|
|
196
159
|
this.config = null;
|
|
@@ -200,7 +163,14 @@ class FraimLocalMCPServer {
|
|
|
200
163
|
this.machineInfo = null;
|
|
201
164
|
this.repoInfo = null;
|
|
202
165
|
this.engine = null;
|
|
203
|
-
this.
|
|
166
|
+
this.fallbackRewriteCount = 0;
|
|
167
|
+
this.fallbackRewriteRequestCount = 0;
|
|
168
|
+
this.fallbackRewriteTokens = new Map();
|
|
169
|
+
this.pendingFallbackRequestCount = 0;
|
|
170
|
+
this.pendingFallbackTokenRewriteCount = 0;
|
|
171
|
+
this.pendingFallbackTokenCounts = new Map();
|
|
172
|
+
this.pendingFallbackEvents = [];
|
|
173
|
+
this.fallbackSummaryEmitted = false;
|
|
204
174
|
this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
205
175
|
this.apiKey = process.env.FRAIM_API_KEY || '';
|
|
206
176
|
this.localVersion = this.detectLocalVersion();
|
|
@@ -331,12 +301,19 @@ class FraimLocalMCPServer {
|
|
|
331
301
|
this.log('✅ Loaded local .fraim/config.json');
|
|
332
302
|
}
|
|
333
303
|
else {
|
|
304
|
+
this.config = null;
|
|
334
305
|
this.log('⚠️ No .fraim/config.json found - template substitution disabled');
|
|
335
306
|
}
|
|
307
|
+
if (this.engine) {
|
|
308
|
+
this.engine.setConfig(this.config);
|
|
309
|
+
}
|
|
336
310
|
}
|
|
337
311
|
catch (error) {
|
|
338
312
|
this.logError(`Failed to load config: ${error}`);
|
|
339
313
|
this.config = null;
|
|
314
|
+
if (this.engine) {
|
|
315
|
+
this.engine.setConfig(this.config);
|
|
316
|
+
}
|
|
340
317
|
}
|
|
341
318
|
}
|
|
342
319
|
/**
|
|
@@ -479,91 +456,340 @@ class FraimLocalMCPServer {
|
|
|
479
456
|
}
|
|
480
457
|
return 'PR';
|
|
481
458
|
}
|
|
482
|
-
|
|
459
|
+
getHomeDir() {
|
|
460
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
461
|
+
return homeDir || null;
|
|
462
|
+
}
|
|
463
|
+
getProviderCachePath(filename) {
|
|
464
|
+
const homeDir = this.getHomeDir();
|
|
465
|
+
if (!homeDir)
|
|
466
|
+
return null;
|
|
467
|
+
return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
|
|
468
|
+
}
|
|
469
|
+
readCachedTemplateFile(filename) {
|
|
470
|
+
try {
|
|
471
|
+
const cachePath = this.getProviderCachePath(filename);
|
|
472
|
+
if (!cachePath || !(0, fs_1.existsSync)(cachePath))
|
|
473
|
+
return null;
|
|
474
|
+
const content = (0, fs_1.readFileSync)(cachePath, 'utf8');
|
|
475
|
+
const parsed = JSON.parse(content);
|
|
476
|
+
if (parsed && typeof parsed === 'object') {
|
|
477
|
+
this.log(`✅ Loaded template cache: ${cachePath}`);
|
|
478
|
+
return parsed;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
this.log(`⚠️ Failed to read template cache ${filename}: ${error.message}`);
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
writeCachedTemplateFile(filename, templates) {
|
|
487
|
+
try {
|
|
488
|
+
const cachePath = this.getProviderCachePath(filename);
|
|
489
|
+
if (!cachePath)
|
|
490
|
+
return;
|
|
491
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(cachePath), { recursive: true });
|
|
492
|
+
(0, fs_1.writeFileSync)(cachePath, JSON.stringify(templates), 'utf8');
|
|
493
|
+
this.log(`✅ Cached template file: ${cachePath}`);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.log(`⚠️ Failed to cache template ${filename}: ${error.message}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
ensureEngine() {
|
|
483
500
|
if (!this.engine) {
|
|
501
|
+
const repoInfo = this.detectRepoInfo();
|
|
484
502
|
this.engine = new FraimTemplateEngine({
|
|
485
503
|
config: this.config,
|
|
486
|
-
repoInfo
|
|
504
|
+
repoInfo,
|
|
487
505
|
workingStyle: this.getWorkingStyle(),
|
|
488
506
|
projectRoot: this.findProjectRoot(),
|
|
489
507
|
logFn: (msg) => this.log(msg)
|
|
490
508
|
});
|
|
491
509
|
}
|
|
492
|
-
|
|
510
|
+
else {
|
|
511
|
+
this.engine.setConfig(this.config);
|
|
512
|
+
if (this.repoInfo) {
|
|
513
|
+
this.engine.setRepoInfo(this.repoInfo);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return this.engine;
|
|
517
|
+
}
|
|
518
|
+
collectStrings(value, output) {
|
|
519
|
+
if (typeof value === 'string') {
|
|
520
|
+
output.push(value);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (Array.isArray(value)) {
|
|
524
|
+
value.forEach(item => this.collectStrings(item, output));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (value && typeof value === 'object') {
|
|
528
|
+
Object.values(value).forEach(item => this.collectStrings(item, output));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
responseContainsDeliveryPlaceholders(response) {
|
|
532
|
+
if (!response.result)
|
|
533
|
+
return false;
|
|
534
|
+
const strings = [];
|
|
535
|
+
this.collectStrings(response.result, strings);
|
|
536
|
+
return strings.some(s => /\{\{proxy\.delivery\.[^}]+\}\}/.test(s));
|
|
537
|
+
}
|
|
538
|
+
responseHasPotentialProviderPlaceholders(response) {
|
|
539
|
+
if (!response.result)
|
|
540
|
+
return false;
|
|
541
|
+
const strings = [];
|
|
542
|
+
this.collectStrings(response.result, strings);
|
|
543
|
+
return strings.some(s => /\{\{proxy\.action\.[^}]+\}\}/.test(s));
|
|
544
|
+
}
|
|
545
|
+
async fetchRegistryFileFromServer(path, requestSessionId) {
|
|
546
|
+
const request = {
|
|
547
|
+
jsonrpc: '2.0',
|
|
548
|
+
id: (0, crypto_1.randomUUID)(),
|
|
549
|
+
method: 'tools/call',
|
|
550
|
+
params: {
|
|
551
|
+
name: 'get_fraim_file',
|
|
552
|
+
arguments: { path }
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
this.applyRequestSessionId(request, requestSessionId);
|
|
556
|
+
const response = await this.proxyToRemote(request);
|
|
557
|
+
if (response.error) {
|
|
558
|
+
this.log(`⚠️ Failed to fetch registry file ${path}: ${response.error.message}`);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const textBlock = response.result?.content?.find((c) => c.type === 'text');
|
|
562
|
+
if (!textBlock?.text) {
|
|
563
|
+
this.log(`⚠️ No text content in registry response for ${path}`);
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return textBlock.text;
|
|
567
|
+
}
|
|
568
|
+
parseTemplateJson(content, context) {
|
|
569
|
+
const candidates = [];
|
|
570
|
+
const trimmed = content.trim();
|
|
571
|
+
candidates.push(trimmed);
|
|
572
|
+
// get_fraim_file responses include a markdown header, then a separator line, then file content.
|
|
573
|
+
const separatorIndex = trimmed.indexOf('\n---\n');
|
|
574
|
+
if (separatorIndex >= 0) {
|
|
575
|
+
candidates.push(trimmed.slice(separatorIndex + '\n---\n'.length).trim());
|
|
576
|
+
}
|
|
577
|
+
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
578
|
+
if (codeFenceMatch?.[1]) {
|
|
579
|
+
candidates.push(codeFenceMatch[1].trim());
|
|
580
|
+
}
|
|
581
|
+
const firstBrace = trimmed.indexOf('{');
|
|
582
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
583
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
584
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
585
|
+
}
|
|
586
|
+
for (const candidate of candidates) {
|
|
587
|
+
if (!candidate)
|
|
588
|
+
continue;
|
|
589
|
+
try {
|
|
590
|
+
const parsed = JSON.parse(candidate);
|
|
591
|
+
if (parsed && typeof parsed === 'object') {
|
|
592
|
+
return parsed;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Try the next extraction candidate.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
this.log(`⚠️ Failed to parse ${context}: no valid JSON object found in response`);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
async ensureDeliveryTemplatesAvailable(response, requestSessionId) {
|
|
603
|
+
if (!this.responseContainsDeliveryPlaceholders(response))
|
|
604
|
+
return;
|
|
605
|
+
const engine = this.ensureEngine();
|
|
606
|
+
if (engine.hasDeliveryTemplates())
|
|
607
|
+
return;
|
|
608
|
+
if (engine.loadDeliveryTemplates())
|
|
609
|
+
return;
|
|
610
|
+
const filename = this.getWorkingStyle() === 'Conversation'
|
|
611
|
+
? 'delivery-conversation.json'
|
|
612
|
+
: 'delivery-pr.json';
|
|
613
|
+
const cached = this.readCachedTemplateFile(filename);
|
|
614
|
+
if (cached) {
|
|
615
|
+
engine.setDeliveryTemplates(cached);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const remoteContent = await this.fetchRegistryFileFromServer(`providers/${filename}`, requestSessionId);
|
|
619
|
+
if (!remoteContent)
|
|
620
|
+
return;
|
|
621
|
+
const parsed = this.parseTemplateJson(remoteContent, `providers/${filename}`);
|
|
622
|
+
if (!parsed)
|
|
623
|
+
return;
|
|
624
|
+
engine.setDeliveryTemplates(parsed);
|
|
625
|
+
this.writeCachedTemplateFile(filename, parsed);
|
|
626
|
+
}
|
|
627
|
+
async ensureProviderTemplatesAvailable(response, requestSessionId) {
|
|
628
|
+
const hasDirectProviderPlaceholders = this.responseHasPotentialProviderPlaceholders(response);
|
|
629
|
+
const hasDeliveryPlaceholders = this.responseContainsDeliveryPlaceholders(response);
|
|
630
|
+
if (!hasDirectProviderPlaceholders && !hasDeliveryPlaceholders)
|
|
631
|
+
return;
|
|
632
|
+
const engine = this.ensureEngine();
|
|
633
|
+
const provider = (0, provider_utils_1.detectProvider)(this.detectRepoInfo()?.url);
|
|
634
|
+
if (engine.hasProviderTemplates(provider))
|
|
635
|
+
return;
|
|
636
|
+
if (engine.loadProviderTemplates(provider))
|
|
637
|
+
return;
|
|
638
|
+
const filename = `${provider}.json`;
|
|
639
|
+
const cached = this.readCachedTemplateFile(filename);
|
|
640
|
+
if (cached) {
|
|
641
|
+
engine.setProviderTemplates(provider, cached);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const remoteContent = await this.fetchRegistryFileFromServer(`providers/${filename}`, requestSessionId);
|
|
645
|
+
if (!remoteContent)
|
|
646
|
+
return;
|
|
647
|
+
const parsed = this.parseTemplateJson(remoteContent, `providers/${filename}`);
|
|
648
|
+
if (!parsed)
|
|
649
|
+
return;
|
|
650
|
+
engine.setProviderTemplates(provider, parsed);
|
|
651
|
+
this.writeCachedTemplateFile(filename, parsed);
|
|
652
|
+
}
|
|
653
|
+
async hydrateTemplateCachesForResponse(response, requestSessionId) {
|
|
654
|
+
if (!response.result)
|
|
655
|
+
return;
|
|
656
|
+
await this.ensureDeliveryTemplatesAvailable(response, requestSessionId);
|
|
657
|
+
await this.ensureProviderTemplatesAvailable(response, requestSessionId);
|
|
658
|
+
}
|
|
659
|
+
async processResponseWithHydration(response, requestSessionId) {
|
|
660
|
+
await this.hydrateTemplateCachesForResponse(response, requestSessionId);
|
|
661
|
+
let processedResponse = this.processResponse(response);
|
|
662
|
+
// Delivery substitution can introduce provider action placeholders (e.g. {{proxy.action.update_issue_status}})
|
|
663
|
+
// that were not visible pre-substitution. Run one targeted re-hydration pass if needed.
|
|
664
|
+
const provider = (0, provider_utils_1.detectProvider)(this.detectRepoInfo()?.url);
|
|
665
|
+
const engine = this.ensureEngine();
|
|
666
|
+
if (this.responseHasPotentialProviderPlaceholders(processedResponse) &&
|
|
667
|
+
!engine.hasProviderTemplates(provider)) {
|
|
668
|
+
await this.ensureProviderTemplatesAvailable(processedResponse, requestSessionId);
|
|
669
|
+
processedResponse = this.processResponse(processedResponse);
|
|
670
|
+
}
|
|
671
|
+
return this.applyAgentFallbackForUnresolvedProxy(processedResponse);
|
|
672
|
+
}
|
|
673
|
+
rewriteProxyTokensInText(text) {
|
|
674
|
+
const tokens = new Set();
|
|
675
|
+
const rewritten = text.replace(/\{\{\s*proxy\.([^}]+?)\s*\}\}/g, (_match, proxyPath) => {
|
|
676
|
+
const normalized = proxyPath.trim();
|
|
677
|
+
tokens.add(`proxy.${normalized}`);
|
|
678
|
+
return `{{agent.${normalized}}}`;
|
|
679
|
+
});
|
|
680
|
+
if (tokens.size === 0) {
|
|
681
|
+
return { text, tokens: [] };
|
|
682
|
+
}
|
|
683
|
+
const hasResolutionNotice = rewritten.includes('## Agent Resolution Needed');
|
|
684
|
+
const finalText = hasResolutionNotice
|
|
685
|
+
? rewritten
|
|
686
|
+
: `${FraimLocalMCPServer.AGENT_RESOLUTION_NOTICE}\n\n${rewritten}`;
|
|
687
|
+
return { text: finalText, tokens: Array.from(tokens).sort() };
|
|
688
|
+
}
|
|
689
|
+
rewriteUnresolvedProxyPlaceholders(value) {
|
|
690
|
+
const rewrittenTokens = new Set();
|
|
691
|
+
const rewrite = (input) => {
|
|
692
|
+
if (typeof input === 'string') {
|
|
693
|
+
const rewritten = this.rewriteProxyTokensInText(input);
|
|
694
|
+
rewritten.tokens.forEach((token) => rewrittenTokens.add(token));
|
|
695
|
+
return rewritten.text;
|
|
696
|
+
}
|
|
697
|
+
if (Array.isArray(input)) {
|
|
698
|
+
return input.map(rewrite);
|
|
699
|
+
}
|
|
700
|
+
if (input && typeof input === 'object') {
|
|
701
|
+
const output = {};
|
|
702
|
+
for (const [key, nestedValue] of Object.entries(input)) {
|
|
703
|
+
if (key === 'text' && typeof nestedValue === 'string') {
|
|
704
|
+
const rewritten = this.rewriteProxyTokensInText(nestedValue);
|
|
705
|
+
rewritten.tokens.forEach((token) => rewrittenTokens.add(token));
|
|
706
|
+
output[key] = rewritten.text;
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
output[key] = rewrite(nestedValue);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return output;
|
|
713
|
+
}
|
|
714
|
+
return input;
|
|
715
|
+
};
|
|
716
|
+
const rewrittenValue = rewrite(value);
|
|
717
|
+
return { value: rewrittenValue, tokens: Array.from(rewrittenTokens).sort() };
|
|
718
|
+
}
|
|
719
|
+
substituteTemplates(content) {
|
|
720
|
+
return this.ensureEngine().substituteTemplates(content);
|
|
493
721
|
}
|
|
494
722
|
/**
|
|
495
723
|
* Initialize the LocalRegistryResolver for override resolution
|
|
496
724
|
*/
|
|
497
|
-
getRegistryResolver() {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
725
|
+
getRegistryResolver(requestSessionId) {
|
|
726
|
+
const projectRoot = this.findProjectRoot();
|
|
727
|
+
this.log(`🔍 getRegistryResolver: projectRoot = ${projectRoot}`);
|
|
728
|
+
if (!projectRoot) {
|
|
729
|
+
this.log('⚠️ No project root found, override resolution disabled');
|
|
730
|
+
// Return a resolver that always falls back to remote
|
|
731
|
+
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
732
|
+
workspaceRoot: process.cwd(),
|
|
733
|
+
remoteContentResolver: async (_path) => {
|
|
734
|
+
throw new Error('No project root available');
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
740
|
+
workspaceRoot: projectRoot,
|
|
741
|
+
remoteContentResolver: async (path) => {
|
|
742
|
+
// Fetch parent content from remote for inheritance
|
|
743
|
+
this.log(`🔄 Remote content resolver: fetching ${path}`);
|
|
744
|
+
let request;
|
|
745
|
+
if (path.startsWith('workflows/')) {
|
|
746
|
+
// Extract workflow name from path: workflows/category/name.md -> name
|
|
747
|
+
const pathParts = path.replace('workflows/', '').replace('.md', '').split('/');
|
|
748
|
+
const workflowName = pathParts[pathParts.length - 1]; // Get last part (name)
|
|
749
|
+
this.log(`🔄 Fetching workflow: ${workflowName}`);
|
|
750
|
+
request = {
|
|
751
|
+
jsonrpc: '2.0',
|
|
752
|
+
id: (0, crypto_1.randomUUID)(),
|
|
753
|
+
method: 'tools/call',
|
|
754
|
+
params: {
|
|
755
|
+
name: 'get_fraim_workflow',
|
|
756
|
+
arguments: { workflow: workflowName }
|
|
757
|
+
}
|
|
758
|
+
};
|
|
508
759
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
// Extract workflow name from path: workflows/category/name.md -> name
|
|
520
|
-
const pathParts = path.replace('workflows/', '').replace('.md', '').split('/');
|
|
521
|
-
const workflowName = pathParts[pathParts.length - 1]; // Get last part (name)
|
|
522
|
-
this.log(`🔄 Fetching workflow: ${workflowName}`);
|
|
523
|
-
request = {
|
|
524
|
-
jsonrpc: '2.0',
|
|
525
|
-
id: (0, crypto_1.randomUUID)(),
|
|
526
|
-
method: 'tools/call',
|
|
527
|
-
params: {
|
|
528
|
-
name: 'get_fraim_workflow',
|
|
529
|
-
arguments: { workflow: workflowName }
|
|
530
|
-
}
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
else {
|
|
534
|
-
// For non-workflow files (templates, rules, etc.), use get_fraim_file
|
|
535
|
-
this.log(`🔄 Fetching file: ${path}`);
|
|
536
|
-
request = {
|
|
537
|
-
jsonrpc: '2.0',
|
|
538
|
-
id: (0, crypto_1.randomUUID)(),
|
|
539
|
-
method: 'tools/call',
|
|
540
|
-
params: {
|
|
541
|
-
name: 'get_fraim_file',
|
|
542
|
-
arguments: { path }
|
|
543
|
-
}
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
const response = await this.proxyToRemote(request);
|
|
547
|
-
if (response.error) {
|
|
548
|
-
this.logError(`❌ Remote content resolver failed: ${response.error.message}`);
|
|
549
|
-
throw new Error(`Failed to fetch parent: ${response.error.message}`);
|
|
550
|
-
}
|
|
551
|
-
// Extract content from MCP response format
|
|
552
|
-
if (response.result?.content && Array.isArray(response.result.content)) {
|
|
553
|
-
const textContent = response.result.content.find((c) => c.type === 'text');
|
|
554
|
-
if (textContent?.text) {
|
|
555
|
-
this.log(`✅ Remote content resolver: fetched ${textContent.text.length} chars`);
|
|
556
|
-
return textContent.text;
|
|
760
|
+
else {
|
|
761
|
+
// For non-workflow files (templates, rules, etc.), use get_fraim_file
|
|
762
|
+
this.log(`🔄 Fetching file: ${path}`);
|
|
763
|
+
request = {
|
|
764
|
+
jsonrpc: '2.0',
|
|
765
|
+
id: (0, crypto_1.randomUUID)(),
|
|
766
|
+
method: 'tools/call',
|
|
767
|
+
params: {
|
|
768
|
+
name: 'get_fraim_file',
|
|
769
|
+
arguments: { path }
|
|
557
770
|
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
this.applyRequestSessionId(request, requestSessionId);
|
|
774
|
+
const response = await this.proxyToRemote(request);
|
|
775
|
+
if (response.error) {
|
|
776
|
+
this.logError(`❌ Remote content resolver failed: ${response.error.message}`);
|
|
777
|
+
throw new Error(`Failed to fetch parent: ${response.error.message}`);
|
|
778
|
+
}
|
|
779
|
+
// Extract content from MCP response format
|
|
780
|
+
if (response.result?.content && Array.isArray(response.result.content)) {
|
|
781
|
+
const textContent = response.result.content.find((c) => c.type === 'text');
|
|
782
|
+
if (textContent?.text) {
|
|
783
|
+
this.log(`✅ Remote content resolver: fetched ${textContent.text.length} chars`);
|
|
784
|
+
return textContent.text;
|
|
558
785
|
}
|
|
559
|
-
this.logError(`❌ Remote content resolver: no content in response for ${path}`);
|
|
560
|
-
this.logError(`Response: ${JSON.stringify(response, null, 2)}`);
|
|
561
|
-
throw new Error(`No content in remote response for ${path}`);
|
|
562
786
|
}
|
|
563
|
-
|
|
564
|
-
|
|
787
|
+
this.logError(`❌ Remote content resolver: no content in response for ${path}`);
|
|
788
|
+
this.logError(`Response: ${JSON.stringify(response, null, 2)}`);
|
|
789
|
+
throw new Error(`No content in remote response for ${path}`);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
565
792
|
}
|
|
566
|
-
return this.registryResolver;
|
|
567
793
|
}
|
|
568
794
|
/**
|
|
569
795
|
* Determine workflow category from workflow name
|
|
@@ -625,11 +851,121 @@ class FraimLocalMCPServer {
|
|
|
625
851
|
result: processValue(response.result)
|
|
626
852
|
};
|
|
627
853
|
}
|
|
854
|
+
applyAgentFallbackForUnresolvedProxy(response) {
|
|
855
|
+
if (!response.result)
|
|
856
|
+
return response;
|
|
857
|
+
const rewritten = this.rewriteUnresolvedProxyPlaceholders(response.result);
|
|
858
|
+
if (rewritten.tokens.length > 0) {
|
|
859
|
+
this.recordFallbackRewrite(rewritten.tokens);
|
|
860
|
+
this.logError(`[${FraimLocalMCPServer.FALLBACK_ALERT_MARKER}] Rewrote unresolved proxy placeholders to agent placeholders: ${rewritten.tokens.join(', ')}`);
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
...response,
|
|
864
|
+
result: rewritten.value
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
recordFallbackRewrite(tokens) {
|
|
868
|
+
this.fallbackRewriteRequestCount += 1;
|
|
869
|
+
this.fallbackRewriteCount += tokens.length;
|
|
870
|
+
for (const token of tokens) {
|
|
871
|
+
const existing = this.fallbackRewriteTokens.get(token) || 0;
|
|
872
|
+
this.fallbackRewriteTokens.set(token, existing + 1);
|
|
873
|
+
}
|
|
874
|
+
this.pendingFallbackRequestCount += 1;
|
|
875
|
+
this.pendingFallbackTokenRewriteCount += tokens.length;
|
|
876
|
+
for (const token of tokens) {
|
|
877
|
+
const existing = this.pendingFallbackTokenCounts.get(token) || 0;
|
|
878
|
+
this.pendingFallbackTokenCounts.set(token, existing + 1);
|
|
879
|
+
}
|
|
880
|
+
this.pendingFallbackEvents.push({
|
|
881
|
+
at: new Date().toISOString(),
|
|
882
|
+
tokenCount: tokens.length,
|
|
883
|
+
tokens: [...tokens]
|
|
884
|
+
});
|
|
885
|
+
if (this.pendingFallbackEvents.length > 250) {
|
|
886
|
+
this.pendingFallbackEvents = this.pendingFallbackEvents.slice(-250);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
buildPendingFallbackTelemetryPayload() {
|
|
890
|
+
if (this.pendingFallbackRequestCount === 0 &&
|
|
891
|
+
this.pendingFallbackTokenRewriteCount === 0 &&
|
|
892
|
+
this.pendingFallbackEvents.length === 0) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
const tokenCounts = {};
|
|
896
|
+
for (const [token, count] of this.pendingFallbackTokenCounts.entries()) {
|
|
897
|
+
tokenCounts[token] = count;
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
requestFallbacks: this.pendingFallbackRequestCount,
|
|
901
|
+
tokenRewrites: this.pendingFallbackTokenRewriteCount,
|
|
902
|
+
tokenCounts,
|
|
903
|
+
events: this.pendingFallbackEvents.map((event) => ({
|
|
904
|
+
at: event.at,
|
|
905
|
+
tokenCount: event.tokenCount,
|
|
906
|
+
tokens: [...event.tokens]
|
|
907
|
+
}))
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
acknowledgePendingFallbackTelemetry(payload) {
|
|
911
|
+
this.pendingFallbackRequestCount = Math.max(0, this.pendingFallbackRequestCount - payload.requestFallbacks);
|
|
912
|
+
this.pendingFallbackTokenRewriteCount = Math.max(0, this.pendingFallbackTokenRewriteCount - payload.tokenRewrites);
|
|
913
|
+
for (const [token, sentCount] of Object.entries(payload.tokenCounts)) {
|
|
914
|
+
const current = this.pendingFallbackTokenCounts.get(token) || 0;
|
|
915
|
+
const next = Math.max(0, current - Math.max(0, sentCount));
|
|
916
|
+
if (next === 0) {
|
|
917
|
+
this.pendingFallbackTokenCounts.delete(token);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
this.pendingFallbackTokenCounts.set(token, next);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (payload.events.length > 0) {
|
|
924
|
+
this.pendingFallbackEvents.splice(0, Math.min(payload.events.length, this.pendingFallbackEvents.length));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
emitFallbackSummary(reason) {
|
|
928
|
+
if (this.fallbackSummaryEmitted)
|
|
929
|
+
return;
|
|
930
|
+
this.fallbackSummaryEmitted = true;
|
|
931
|
+
if (this.fallbackRewriteCount === 0)
|
|
932
|
+
return;
|
|
933
|
+
const topTokens = Array.from(this.fallbackRewriteTokens.entries())
|
|
934
|
+
.sort((a, b) => b[1] - a[1])
|
|
935
|
+
.slice(0, 10)
|
|
936
|
+
.map(([token, count]) => `${token}:${count}`)
|
|
937
|
+
.join(', ');
|
|
938
|
+
this.logError(`[${FraimLocalMCPServer.FALLBACK_SUMMARY_MARKER}] reason=${reason} requestFallbacks=${this.fallbackRewriteRequestCount} tokenRewrites=${this.fallbackRewriteCount} uniqueTokens=${this.fallbackRewriteTokens.size}${topTokens ? ` topTokens=${topTokens}` : ''}`);
|
|
939
|
+
}
|
|
940
|
+
extractSessionIdFromRequest(request) {
|
|
941
|
+
const sessionIdFromParams = request.params?.sessionId;
|
|
942
|
+
if (typeof sessionIdFromParams === 'string' && sessionIdFromParams.trim().length > 0) {
|
|
943
|
+
return sessionIdFromParams.trim();
|
|
944
|
+
}
|
|
945
|
+
const sessionIdFromArgs = request.params?.arguments?.sessionId;
|
|
946
|
+
if (typeof sessionIdFromArgs === 'string' && sessionIdFromArgs.trim().length > 0) {
|
|
947
|
+
return sessionIdFromArgs.trim();
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
applyRequestSessionId(request, requestSessionId) {
|
|
952
|
+
if (!requestSessionId || requestSessionId.trim().length === 0)
|
|
953
|
+
return;
|
|
954
|
+
request.params = request.params || {};
|
|
955
|
+
if (typeof request.params.sessionId !== 'string' || request.params.sessionId.trim().length === 0) {
|
|
956
|
+
request.params.sessionId = requestSessionId;
|
|
957
|
+
}
|
|
958
|
+
request.params.arguments = request.params.arguments || {};
|
|
959
|
+
if (typeof request.params.arguments.sessionId !== 'string' || request.params.arguments.sessionId.trim().length === 0) {
|
|
960
|
+
request.params.arguments.sessionId = requestSessionId;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
628
963
|
/**
|
|
629
964
|
* Proxy request to remote FRAIM server
|
|
630
965
|
*/
|
|
631
966
|
async proxyToRemote(request) {
|
|
632
967
|
const requestId = (0, crypto_1.randomUUID)();
|
|
968
|
+
let sentFallbackTelemetry = null;
|
|
633
969
|
try {
|
|
634
970
|
// Special handling for fraim_connect - automatically inject machine and repo info
|
|
635
971
|
if (request.method === 'tools/call' && request.params?.name === 'fraim_connect') {
|
|
@@ -670,19 +1006,38 @@ class FraimLocalMCPServer {
|
|
|
670
1006
|
}
|
|
671
1007
|
this.log(`[req:${requestId}] Using agent-provided repo info: ${args.repo.owner}/${args.repo.name}`);
|
|
672
1008
|
}
|
|
1009
|
+
// Keep proxy runtime repository context in sync with connect payload.
|
|
1010
|
+
this.repoInfo = args.repo;
|
|
1011
|
+
if (this.engine) {
|
|
1012
|
+
this.engine.setRepoInfo(args.repo);
|
|
1013
|
+
}
|
|
673
1014
|
// Update the request with injected info
|
|
674
1015
|
request.params.arguments = args;
|
|
675
1016
|
}
|
|
1017
|
+
sentFallbackTelemetry = this.buildPendingFallbackTelemetryPayload();
|
|
1018
|
+
const headers = {
|
|
1019
|
+
'Content-Type': 'application/json',
|
|
1020
|
+
'x-api-key': this.apiKey,
|
|
1021
|
+
'x-fraim-request-id': requestId,
|
|
1022
|
+
'x-fraim-local-version': this.localVersion
|
|
1023
|
+
};
|
|
1024
|
+
const sessionId = this.extractSessionIdFromRequest(request);
|
|
1025
|
+
if (sessionId) {
|
|
1026
|
+
headers[FraimLocalMCPServer.SESSION_HEADER] = sessionId;
|
|
1027
|
+
}
|
|
1028
|
+
if (sentFallbackTelemetry) {
|
|
1029
|
+
headers[FraimLocalMCPServer.FALLBACK_HEADER] = Buffer
|
|
1030
|
+
.from(JSON.stringify(sentFallbackTelemetry), 'utf8')
|
|
1031
|
+
.toString('base64');
|
|
1032
|
+
}
|
|
676
1033
|
this.log(`[req:${requestId}] Proxying ${request.method} to ${this.remoteUrl}/mcp`);
|
|
677
1034
|
const response = await axios_1.default.post(`${this.remoteUrl}/mcp`, request, {
|
|
678
|
-
headers
|
|
679
|
-
'Content-Type': 'application/json',
|
|
680
|
-
'x-api-key': this.apiKey,
|
|
681
|
-
'x-fraim-request-id': requestId,
|
|
682
|
-
'x-fraim-local-version': this.localVersion
|
|
683
|
-
},
|
|
1035
|
+
headers,
|
|
684
1036
|
timeout: 30000
|
|
685
1037
|
});
|
|
1038
|
+
if (sentFallbackTelemetry) {
|
|
1039
|
+
this.acknowledgePendingFallbackTelemetry(sentFallbackTelemetry);
|
|
1040
|
+
}
|
|
686
1041
|
return response.data;
|
|
687
1042
|
}
|
|
688
1043
|
catch (error) {
|
|
@@ -707,6 +1062,10 @@ class FraimLocalMCPServer {
|
|
|
707
1062
|
localMcpVersion: this.localVersion
|
|
708
1063
|
};
|
|
709
1064
|
}
|
|
1065
|
+
if (sentFallbackTelemetry) {
|
|
1066
|
+
// Request reached the server and produced a response; clear sent fallback delta.
|
|
1067
|
+
this.acknowledgePendingFallbackTelemetry(sentFallbackTelemetry);
|
|
1068
|
+
}
|
|
710
1069
|
return forwarded;
|
|
711
1070
|
}
|
|
712
1071
|
return {
|
|
@@ -757,6 +1116,7 @@ class FraimLocalMCPServer {
|
|
|
757
1116
|
*/
|
|
758
1117
|
async handleRequest(request) {
|
|
759
1118
|
this.log(`📥 ${request.method}`);
|
|
1119
|
+
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
760
1120
|
// Special handling for initialize request
|
|
761
1121
|
if (request.method === 'initialize') {
|
|
762
1122
|
// Check if client supports roots
|
|
@@ -767,12 +1127,15 @@ class FraimLocalMCPServer {
|
|
|
767
1127
|
}
|
|
768
1128
|
// Proxy initialize to remote server first
|
|
769
1129
|
const response = await this.proxyToRemote(request);
|
|
770
|
-
const processedResponse = this.
|
|
1130
|
+
const processedResponse = await this.processResponseWithHydration(response, requestSessionId);
|
|
771
1131
|
// After successful initialization, load config
|
|
772
1132
|
if (!processedResponse.error) {
|
|
773
|
-
//
|
|
774
|
-
//
|
|
1133
|
+
// Load config immediately for compatibility, then request roots so
|
|
1134
|
+
// workspace-aware clients (Codex, Cursor, etc.) can provide exact roots.
|
|
775
1135
|
this.loadConfig();
|
|
1136
|
+
if (this.clientSupportsRoots) {
|
|
1137
|
+
setImmediate(() => this.requestRootsFromClient());
|
|
1138
|
+
}
|
|
776
1139
|
}
|
|
777
1140
|
this.log(`📤 ${request.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
778
1141
|
return processedResponse;
|
|
@@ -797,7 +1160,7 @@ class FraimLocalMCPServer {
|
|
|
797
1160
|
const category = this.getWorkflowCategory(workflowName);
|
|
798
1161
|
requestedPath = `workflows/${category}/${workflowName}.md`;
|
|
799
1162
|
this.log(`🔍 Checking for override: ${requestedPath}`);
|
|
800
|
-
const resolver = this.getRegistryResolver();
|
|
1163
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
801
1164
|
const hasOverride = resolver.hasLocalOverride(requestedPath);
|
|
802
1165
|
this.log(`🔍 hasLocalOverride(${requestedPath}) = ${hasOverride}`);
|
|
803
1166
|
if (hasOverride) {
|
|
@@ -818,7 +1181,7 @@ class FraimLocalMCPServer {
|
|
|
818
1181
|
}
|
|
819
1182
|
};
|
|
820
1183
|
// Apply template substitution
|
|
821
|
-
const processedResponse = this.
|
|
1184
|
+
const processedResponse = await this.processResponseWithHydration(response, requestSessionId);
|
|
822
1185
|
this.log(`📤 ${request.method} → OK`);
|
|
823
1186
|
return processedResponse;
|
|
824
1187
|
}
|
|
@@ -830,31 +1193,37 @@ class FraimLocalMCPServer {
|
|
|
830
1193
|
this.log('⚠️ No path provided in get_fraim_file');
|
|
831
1194
|
}
|
|
832
1195
|
else {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
this.log(
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1196
|
+
if (requestedPath.startsWith('providers/')) {
|
|
1197
|
+
// Server-authoritative templates: never resolve provider files via local overrides.
|
|
1198
|
+
this.log(`🔒 Skipping local override for provider template file: ${requestedPath}`);
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
this.log(`🔍 Checking for override: ${requestedPath}`);
|
|
1202
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
1203
|
+
const hasOverride = resolver.hasLocalOverride(requestedPath);
|
|
1204
|
+
this.log(`🔍 hasLocalOverride(${requestedPath}) = ${hasOverride}`);
|
|
1205
|
+
if (hasOverride) {
|
|
1206
|
+
this.log(`✅ Local override found: ${requestedPath}`);
|
|
1207
|
+
const resolved = await resolver.resolveFile(requestedPath);
|
|
1208
|
+
this.log(`📝 Override resolved (source: ${resolved.source}, inherited: ${resolved.inherited})`);
|
|
1209
|
+
// Build MCP response with resolved content
|
|
1210
|
+
const response = {
|
|
1211
|
+
jsonrpc: '2.0',
|
|
1212
|
+
id: request.id,
|
|
1213
|
+
result: {
|
|
1214
|
+
content: [
|
|
1215
|
+
{
|
|
1216
|
+
type: 'text',
|
|
1217
|
+
text: resolved.content
|
|
1218
|
+
}
|
|
1219
|
+
]
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
// Apply template substitution
|
|
1223
|
+
const processedResponse = await this.processResponseWithHydration(response, requestSessionId);
|
|
1224
|
+
this.log(`📤 ${request.method} → OK`);
|
|
1225
|
+
return processedResponse;
|
|
1226
|
+
}
|
|
858
1227
|
}
|
|
859
1228
|
}
|
|
860
1229
|
}
|
|
@@ -867,8 +1236,7 @@ class FraimLocalMCPServer {
|
|
|
867
1236
|
}
|
|
868
1237
|
// Proxy to remote server
|
|
869
1238
|
const response = await this.proxyToRemote(request);
|
|
870
|
-
|
|
871
|
-
const processedResponse = this.processResponse(response);
|
|
1239
|
+
const processedResponse = await this.processResponseWithHydration(response, requestSessionId);
|
|
872
1240
|
this.log(`📤 ${request.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
873
1241
|
return processedResponse;
|
|
874
1242
|
}
|
|
@@ -891,11 +1259,12 @@ class FraimLocalMCPServer {
|
|
|
891
1259
|
// Use the first root (typically the primary workspace folder)
|
|
892
1260
|
const firstRoot = roots[0];
|
|
893
1261
|
if (firstRoot.uri && firstRoot.uri.startsWith('file://')) {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1262
|
+
const rootPath = this.fileUriToLocalPath(firstRoot.uri);
|
|
1263
|
+
if (!rootPath) {
|
|
1264
|
+
this.log(`⚠️ Could not parse root URI: ${firstRoot.uri}`);
|
|
1265
|
+
this.loadConfig();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
899
1268
|
this.log(`✅ Got workspace root from client: ${rootPath} (${firstRoot.name || 'unnamed'})`);
|
|
900
1269
|
this.workspaceRoot = rootPath;
|
|
901
1270
|
// Now load config with the correct workspace root
|
|
@@ -913,6 +1282,47 @@ class FraimLocalMCPServer {
|
|
|
913
1282
|
}
|
|
914
1283
|
}
|
|
915
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Convert a file:// URI to a local filesystem path.
|
|
1287
|
+
*/
|
|
1288
|
+
fileUriToLocalPath(uri) {
|
|
1289
|
+
if (!uri.startsWith('file://'))
|
|
1290
|
+
return null;
|
|
1291
|
+
let pathname = '';
|
|
1292
|
+
let host = '';
|
|
1293
|
+
try {
|
|
1294
|
+
const parsed = new URL(uri);
|
|
1295
|
+
if (parsed.protocol !== 'file:')
|
|
1296
|
+
return null;
|
|
1297
|
+
pathname = parsed.pathname || '';
|
|
1298
|
+
host = parsed.host || '';
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
const raw = uri.replace('file://', '');
|
|
1302
|
+
try {
|
|
1303
|
+
pathname = decodeURIComponent(raw);
|
|
1304
|
+
}
|
|
1305
|
+
catch {
|
|
1306
|
+
pathname = raw;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
pathname = decodeURIComponent(pathname);
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
// Keep raw path if decoding fails
|
|
1314
|
+
}
|
|
1315
|
+
let localPath = pathname;
|
|
1316
|
+
if (host && host !== 'localhost') {
|
|
1317
|
+
localPath = `//${host}${localPath}`;
|
|
1318
|
+
}
|
|
1319
|
+
// Windows drive letters can arrive as /C:/... or /c:/... or decoded from /c%3A/...
|
|
1320
|
+
localPath = localPath.replace(/^\/([a-zA-Z]:)/, '$1');
|
|
1321
|
+
if (process.platform === 'win32') {
|
|
1322
|
+
localPath = localPath.replace(/\//g, '\\');
|
|
1323
|
+
}
|
|
1324
|
+
return localPath;
|
|
1325
|
+
}
|
|
916
1326
|
/**
|
|
917
1327
|
* Start STDIO server
|
|
918
1328
|
*/
|
|
@@ -974,20 +1384,31 @@ class FraimLocalMCPServer {
|
|
|
974
1384
|
});
|
|
975
1385
|
process.stdin.on('end', () => {
|
|
976
1386
|
this.log('🛑 Stdin closed, shutting down...');
|
|
1387
|
+
this.emitFallbackSummary('stdin_end');
|
|
977
1388
|
process.exit(0);
|
|
978
1389
|
});
|
|
979
1390
|
process.on('SIGTERM', () => {
|
|
980
1391
|
this.log('🛑 SIGTERM received, shutting down...');
|
|
1392
|
+
this.emitFallbackSummary('sigterm');
|
|
981
1393
|
process.exit(0);
|
|
982
1394
|
});
|
|
983
1395
|
process.on('SIGINT', () => {
|
|
984
1396
|
this.log('🛑 SIGINT received, shutting down...');
|
|
1397
|
+
this.emitFallbackSummary('sigint');
|
|
985
1398
|
process.exit(0);
|
|
986
1399
|
});
|
|
987
1400
|
this.log('✅ FRAIM Local MCP Server ready');
|
|
988
1401
|
}
|
|
989
1402
|
}
|
|
990
1403
|
exports.FraimLocalMCPServer = FraimLocalMCPServer;
|
|
1404
|
+
FraimLocalMCPServer.AGENT_RESOLUTION_NOTICE = [
|
|
1405
|
+
'## Agent Resolution Needed',
|
|
1406
|
+
'Replace all `agent.*` placeholders with the best value from repository context, user intent, and available project config.'
|
|
1407
|
+
].join('\n');
|
|
1408
|
+
FraimLocalMCPServer.FALLBACK_ALERT_MARKER = 'PROXY_FALLBACK_ALERT';
|
|
1409
|
+
FraimLocalMCPServer.FALLBACK_SUMMARY_MARKER = 'PROXY_FALLBACK_SUMMARY';
|
|
1410
|
+
FraimLocalMCPServer.FALLBACK_HEADER = 'x-fraim-proxy-fallback-telemetry';
|
|
1411
|
+
FraimLocalMCPServer.SESSION_HEADER = 'x-fraim-session-id';
|
|
991
1412
|
// Start server if run directly
|
|
992
1413
|
if (require.main === module) {
|
|
993
1414
|
const server = new FraimLocalMCPServer();
|