agent-tool-forge 0.3.0

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. package/widget/forge-chat.js +789 -0
@@ -0,0 +1,30 @@
1
+ export interface RateLimitResult {
2
+ allowed: boolean;
3
+ /** Seconds until the current window resets. Present only when `allowed` is false. */
4
+ retryAfter?: number;
5
+ }
6
+
7
+ /**
8
+ * Fixed-window per-user per-route rate limiter.
9
+ *
10
+ * Backend is selected automatically:
11
+ * - Redis — if a Redis client is provided (uses INCR + EXPIRE per window key)
12
+ * - Memory — in-process Map fallback, resets on window boundary
13
+ *
14
+ * Only counts authenticated requests — limits by `userId`, not IP.
15
+ */
16
+ export class RateLimiter {
17
+ constructor(config?: object, redis?: object | null);
18
+
19
+ /**
20
+ * Check whether a request is within the rate limit and increment the counter.
21
+ * Always returns `{ allowed: true }` when rate limiting is disabled.
22
+ */
23
+ check(userId: string, route: string): Promise<RateLimitResult>;
24
+ }
25
+
26
+ /**
27
+ * Factory — creates a RateLimiter from forge config.
28
+ * Reads `config.rateLimit` for `enabled`, `windowMs`, and `maxRequests`.
29
+ */
30
+ export function makeRateLimiter(config: object, redis?: object | null): RateLimiter;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * RateLimiter — fixed-window per-user per-route rate limiting.
3
+ *
4
+ * Auto-detects backend:
5
+ * Redis — if a Redis client is provided (INCR + EXPIRE per window key)
6
+ * Memory — fallback Map, resets on window boundary
7
+ *
8
+ * Only applied to authenticated requests — rate-limits by userId, not IP.
9
+ */
10
+
11
+ export class RateLimiter {
12
+ /**
13
+ * @param {object} config — forge rateLimit config block
14
+ * @param {object} [redis] — ioredis / node-redis-compatible client (optional)
15
+ */
16
+ constructor(config = {}, redis = null) {
17
+ this._enabled = config.enabled ?? false;
18
+ this._windowMs = config.windowMs ?? 60_000;
19
+ this._maxRequests = config.maxRequests ?? 60;
20
+ this._redis = redis;
21
+ // In-memory fallback: Map<`${userId}:${route}`, { count, windowStart }>
22
+ this._store = new Map();
23
+ if (this._enabled) {
24
+ this._sweepTimer = setInterval(() => {
25
+ const now = Date.now();
26
+ const windowMs = this._windowMs;
27
+ for (const [k, v] of this._store) {
28
+ if (Math.floor(now / windowMs) * windowMs !== v.windowStart) {
29
+ this._store.delete(k);
30
+ }
31
+ }
32
+ }, this._windowMs).unref();
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Check if a request is allowed under the rate limit.
38
+ * Increments the counter and returns the decision synchronously (memory)
39
+ * or asynchronously (Redis).
40
+ *
41
+ * @param {string} userId
42
+ * @param {string} route
43
+ * @returns {Promise<{ allowed: boolean, retryAfter?: number }>}
44
+ */
45
+ async check(userId, route) {
46
+ if (!this._enabled) return { allowed: true };
47
+ if (!userId || !route) return { allowed: true };
48
+
49
+ const windowMs = this._windowMs;
50
+ const maxRequests = this._maxRequests;
51
+ const now = Date.now();
52
+ const windowStart = Math.floor(now / windowMs) * windowMs;
53
+ // Use null-byte separator to prevent collisions when userId or route contains ':'
54
+ // (e.g. userId "user:admin" + route "chat" must not collide with userId "user" + route "admin:chat")
55
+ const key = `\x00${userId}\x00${route}`;
56
+
57
+ if (this._redis) {
58
+ return this._checkRedis(key, now, windowMs, maxRequests, windowStart);
59
+ }
60
+ return this._checkMemory(key, now, windowMs, maxRequests, windowStart);
61
+ }
62
+
63
+ /** @private */
64
+ async _checkRedis(key, now, windowMs, maxRequests, windowStart) {
65
+ const redisKey = `forge:rl:${key}:${windowStart}`;
66
+ const ttlSeconds = Math.ceil(windowMs / 1000);
67
+ const count = await this._redis.incr(redisKey);
68
+ if (count === 1) await this._redis.expire(redisKey, ttlSeconds);
69
+ if (count > maxRequests) {
70
+ const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
71
+ return { allowed: false, retryAfter };
72
+ }
73
+ return { allowed: true };
74
+ }
75
+
76
+ /** @private */
77
+ _checkMemory(key, now, windowMs, maxRequests, windowStart) {
78
+ const entry = this._store.get(key);
79
+ if (!entry || entry.windowStart !== windowStart) {
80
+ // Prune the stale entry before creating the new window entry to prevent unbounded Map growth.
81
+ if (entry) this._store.delete(key);
82
+ this._store.set(key, { count: 1, windowStart });
83
+ return { allowed: true };
84
+ }
85
+ entry.count += 1;
86
+ if (entry.count > maxRequests) {
87
+ const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
88
+ return { allowed: false, retryAfter };
89
+ }
90
+ return { allowed: true };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Factory — creates a RateLimiter from forge config.
96
+ * Auto-passes Redis client if available (set by buildSidecarContext).
97
+ *
98
+ * @param {object} config — merged forge config
99
+ * @param {object} [redis] — optional Redis client
100
+ * @returns {RateLimiter}
101
+ */
102
+ export function makeRateLimiter(config, redis = null) {
103
+ return new RateLimiter(config.rateLimit ?? {}, redis);
104
+ }
@@ -0,0 +1,110 @@
1
+ export type ReactEventType =
2
+ | 'text'
3
+ | 'text_delta'
4
+ | 'tool_call'
5
+ | 'tool_result'
6
+ | 'tool_warning'
7
+ | 'hitl'
8
+ | 'error'
9
+ | 'done';
10
+
11
+ export interface TextEvent {
12
+ type: 'text';
13
+ content: string;
14
+ }
15
+
16
+ export interface TextDeltaEvent {
17
+ type: 'text_delta';
18
+ content: string;
19
+ }
20
+
21
+ export interface ToolCallEvent {
22
+ type: 'tool_call';
23
+ id: string;
24
+ tool: string;
25
+ args: Record<string, unknown>;
26
+ }
27
+
28
+ export interface ToolResultEvent {
29
+ type: 'tool_result';
30
+ id: string;
31
+ tool?: string;
32
+ result: unknown;
33
+ }
34
+
35
+ export interface ToolWarningEvent {
36
+ type: 'tool_warning';
37
+ tool: string;
38
+ message: string;
39
+ verifier?: string;
40
+ }
41
+
42
+ export interface HitlEvent {
43
+ type: 'hitl';
44
+ tool?: string;
45
+ message?: string;
46
+ resumeToken?: string;
47
+ verifier?: string;
48
+ conversationMessages?: unknown[];
49
+ pendingToolCalls?: unknown[];
50
+ turnIndex?: number;
51
+ args?: Record<string, unknown>;
52
+ }
53
+
54
+ export interface ErrorEvent {
55
+ type: 'error';
56
+ message: string;
57
+ }
58
+
59
+ export interface DoneEvent {
60
+ type: 'done';
61
+ usage?: {
62
+ inputTokens?: number;
63
+ outputTokens?: number;
64
+ [key: string]: unknown;
65
+ };
66
+ }
67
+
68
+ export type ReactEvent =
69
+ | TextEvent
70
+ | TextDeltaEvent
71
+ | ToolCallEvent
72
+ | ToolResultEvent
73
+ | ToolWarningEvent
74
+ | HitlEvent
75
+ | ErrorEvent
76
+ | DoneEvent;
77
+
78
+ export interface ReactMessage {
79
+ role: 'user' | 'assistant' | 'tool';
80
+ content: unknown;
81
+ }
82
+
83
+ export interface ReactLoopParams {
84
+ provider: string;
85
+ apiKey: string;
86
+ model: string;
87
+ systemPrompt: string;
88
+ messages: ReactMessage[];
89
+ tools?: unknown[];
90
+ maxTurns?: number;
91
+ maxTokens?: number;
92
+ forgeConfig?: object;
93
+ db?: object | null;
94
+ userJwt?: string | null;
95
+ stream?: boolean;
96
+ hooks?: {
97
+ shouldPause?: (toolMeta: object) => { pause: boolean; message?: string };
98
+ onAfterToolCall?: (toolName: string, args: object, result: unknown) => Promise<{ outcome: 'pass' | 'warn' | 'block'; message?: string | null; verifierName?: string }>;
99
+ };
100
+ }
101
+
102
+ export function reactLoop(params: ReactLoopParams): AsyncIterable<ReactEvent>;
103
+
104
+ export function executeToolCall(
105
+ toolName: string,
106
+ args: object,
107
+ forgeConfig: object,
108
+ db: object | null,
109
+ userJwt: string | null
110
+ ): Promise<{ status: number; body: object; error: string | null }>;
@@ -0,0 +1,337 @@
1
+ /**
2
+ * ReAct Engine — standalone ReAct loop module.
3
+ *
4
+ * Async generator that yields typed events. Transport-agnostic:
5
+ * caller converts events to SSE, console output, or whatever.
6
+ *
7
+ * Event types:
8
+ * text — assistant text chunk
9
+ * tool_call — assistant wants to call a tool
10
+ * tool_result — tool execution result
11
+ * tool_warning — verifier returned 'warn' outcome
12
+ * hitl — loop paused for human confirmation
13
+ * done — loop complete (includes usage summary)
14
+ * error — unrecoverable error
15
+ */
16
+
17
+ import { llmTurn, llmTurnStreaming, normalizeUsage } from './api-client.js';
18
+ import { getAllToolRegistry, insertMcpCallLog } from './db.js';
19
+
20
+ /**
21
+ * @typedef {'text'|'text_delta'|'tool_call'|'tool_result'|'tool_warning'|'hitl'|'done'|'error'} ReactEventType
22
+ * @typedef {{ type: ReactEventType, content?, tool?, args?, result?, resumeToken?, message?, usage? }} ReactEvent
23
+ */
24
+
25
+ /**
26
+ * Run a ReAct loop. Yields ReactEvent objects via async generator.
27
+ *
28
+ * @param {object} opts
29
+ * @param {string} opts.provider
30
+ * @param {string} opts.apiKey
31
+ * @param {string} opts.model
32
+ * @param {string} opts.systemPrompt
33
+ * @param {object[]} opts.tools — forge-format tool defs
34
+ * @param {object[]} opts.messages — conversation history
35
+ * @param {number} [opts.maxTurns=10] — safety limit
36
+ * @param {number} [opts.maxTokens=4096] — per-turn
37
+ * @param {object} opts.forgeConfig — for tool routing
38
+ * @param {Database} opts.db — for tool registry reads + MCP log writes
39
+ * @param {string|null} [opts.userJwt] — forwarded to tool HTTP calls
40
+ * @param {boolean} [opts.stream=false] — enable token-level streaming
41
+ * @param {object} [opts.hooks] — { shouldPause, onAfterToolCall }
42
+ * @yields {ReactEvent}
43
+ */
44
+ export async function* reactLoop(opts) {
45
+ const {
46
+ provider, apiKey, model, systemPrompt, tools, messages,
47
+ maxTurns = 10, maxTokens = 4096,
48
+ forgeConfig = {}, db = null, userJwt = null,
49
+ hooks = {}, stream = false
50
+ } = opts;
51
+
52
+ const shouldPause = hooks.shouldPause ?? (() => ({ pause: false }));
53
+ const onAfterToolCall = hooks.onAfterToolCall ?? (() => ({ outcome: 'pass' }));
54
+
55
+ const conversationMessages = [...messages];
56
+ let totalUsage = { inputTokens: 0, outputTokens: 0 };
57
+
58
+ for (let turn = 0; turn < maxTurns; turn++) {
59
+ let response;
60
+ try {
61
+ if (stream) {
62
+ let fullText = '';
63
+ const streamGen = llmTurnStreaming({
64
+ provider, apiKey, model,
65
+ system: systemPrompt,
66
+ messages: conversationMessages,
67
+ tools,
68
+ maxTokens
69
+ });
70
+ for await (const chunk of streamGen) {
71
+ if (chunk.type === 'text_delta') {
72
+ yield { type: 'text_delta', content: chunk.text };
73
+ fullText += chunk.text;
74
+ } else if (chunk.type === 'done') {
75
+ fullText = chunk.text; // authoritative
76
+ response = {
77
+ text: fullText,
78
+ toolCalls: chunk.toolCalls,
79
+ rawContent: null,
80
+ stopReason: chunk.stopReason,
81
+ usage: chunk.usage
82
+ };
83
+ }
84
+ }
85
+ if (!response) {
86
+ yield { type: 'error', message: 'LLM stream ended without completion' };
87
+ return;
88
+ }
89
+ } else {
90
+ response = await llmTurn({
91
+ provider, apiKey, model,
92
+ system: systemPrompt,
93
+ messages: conversationMessages,
94
+ tools,
95
+ maxTokens
96
+ });
97
+ }
98
+ } catch (err) {
99
+ yield { type: 'error', message: `LLM call failed: ${err.message}` };
100
+ return;
101
+ }
102
+
103
+ // Accumulate usage
104
+ if (response.usage) {
105
+ const normalized = normalizeUsage(response.usage, provider);
106
+ totalUsage.inputTokens += normalized.inputTokens;
107
+ totalUsage.outputTokens += normalized.outputTokens;
108
+ }
109
+
110
+ // Emit text if present
111
+ if (response.text) {
112
+ yield { type: 'text', content: response.text };
113
+ }
114
+
115
+ // If no tool calls, the loop is done
116
+ if (!response.toolCalls || response.toolCalls.length === 0) {
117
+ yield { type: 'done', usage: totalUsage };
118
+ return;
119
+ }
120
+
121
+ // Process tool calls
122
+ const toolResults = [];
123
+ for (const toolCall of response.toolCalls) {
124
+ yield { type: 'tool_call', tool: toolCall.name, args: toolCall.input, id: toolCall.id };
125
+
126
+ // Check HITL before execution
127
+ let pauseCheck;
128
+ try { pauseCheck = shouldPause(toolCall); } catch { pauseCheck = { pause: false }; }
129
+ if (pauseCheck.pause) {
130
+ yield {
131
+ type: 'hitl',
132
+ tool: toolCall.name,
133
+ args: toolCall.input,
134
+ message: pauseCheck.message ?? 'Tool call requires confirmation',
135
+ pendingToolCalls: response.toolCalls,
136
+ conversationMessages: [...conversationMessages],
137
+ turnIndex: turn
138
+ };
139
+ return;
140
+ }
141
+
142
+ // Execute tool
143
+ let result;
144
+ try {
145
+ result = await executeToolCall(toolCall.name, toolCall.input, forgeConfig, db, userJwt);
146
+ } catch (err) {
147
+ result = { status: 0, body: { error: err.message }, error: err.message };
148
+ }
149
+
150
+ yield { type: 'tool_result', tool: toolCall.name, result: result.body, id: toolCall.id };
151
+
152
+ // Run verifiers
153
+ let verifyResult;
154
+ try { verifyResult = await onAfterToolCall(toolCall.name, toolCall.input, result); } catch { verifyResult = { outcome: 'pass' }; }
155
+ if (verifyResult.outcome === 'warn') {
156
+ yield { type: 'tool_warning', tool: toolCall.name, message: verifyResult.message, verifier: verifyResult.verifierName };
157
+ } else if (verifyResult.outcome === 'block') {
158
+ yield {
159
+ type: 'hitl',
160
+ tool: toolCall.name,
161
+ message: verifyResult.message ?? 'Verifier blocked tool result',
162
+ verifier: verifyResult.verifierName,
163
+ pendingToolCalls: response.toolCalls,
164
+ conversationMessages: [...conversationMessages],
165
+ turnIndex: turn
166
+ };
167
+ return;
168
+ }
169
+
170
+ toolResults.push({ toolCall, result });
171
+ }
172
+
173
+ // Add tool call history in the correct format for the provider
174
+ if (provider === 'anthropic') {
175
+ // Anthropic expects assistant message with content array, then user message with tool_result blocks
176
+ conversationMessages.push({
177
+ role: 'assistant',
178
+ content: [
179
+ ...(response.text ? [{ type: 'text', text: response.text }] : []),
180
+ ...toolResults.map(({ toolCall }) => ({
181
+ type: 'tool_use',
182
+ id: toolCall.id,
183
+ name: toolCall.name,
184
+ input: toolCall.input
185
+ }))
186
+ ]
187
+ });
188
+ conversationMessages.push({
189
+ role: 'user',
190
+ content: toolResults.map(({ toolCall, result }) => ({
191
+ type: 'tool_result',
192
+ tool_use_id: toolCall.id,
193
+ content: JSON.stringify(result.body)
194
+ }))
195
+ });
196
+ } else {
197
+ // OpenAI-compatible: assistant message with tool_calls array, then individual tool role messages
198
+ conversationMessages.push({
199
+ role: 'assistant',
200
+ content: response.text || null,
201
+ tool_calls: toolResults.map(({ toolCall }) => ({
202
+ id: toolCall.id,
203
+ type: 'function',
204
+ function: { name: toolCall.name, arguments: JSON.stringify(toolCall.input) }
205
+ }))
206
+ });
207
+ for (const { toolCall, result } of toolResults) {
208
+ conversationMessages.push({
209
+ role: 'tool',
210
+ tool_call_id: toolCall.id,
211
+ content: JSON.stringify(result.body)
212
+ });
213
+ }
214
+ }
215
+ }
216
+
217
+ // Safety limit reached
218
+ yield { type: 'error', message: `ReAct loop reached maxTurns limit (${maxTurns})` };
219
+ }
220
+
221
+ /**
222
+ * Execute a single tool call — routes to the app's API endpoint.
223
+ * Adapted from mcp-server.js callToolEndpoint for direct use by the ReAct loop.
224
+ *
225
+ * @param {string} toolName
226
+ * @param {object} args
227
+ * @param {object} forgeConfig — forge config with api.baseUrl
228
+ * @param {Database|null} db — for MCP call logging
229
+ * @param {string|null} userJwt — forwarded as Authorization header
230
+ * @returns {Promise<{ status: number, body: object, error: string|null }>}
231
+ */
232
+ export async function executeToolCall(toolName, args, forgeConfig, db, userJwt) {
233
+ // Look up tool spec in registry
234
+ let spec;
235
+ if (db) {
236
+ const rows = getAllToolRegistry(db).filter(r => r.lifecycle_state === 'promoted');
237
+ const row = rows.find(r => r.tool_name === toolName);
238
+ if (!row) {
239
+ return { status: 404, body: { error: `Tool "${toolName}" not found in registry` }, error: 'Tool not found' };
240
+ }
241
+ try {
242
+ spec = JSON.parse(row.spec_json);
243
+ } catch {
244
+ return { status: 500, body: { error: `Tool "${toolName}" has malformed spec_json` }, error: 'Malformed spec' };
245
+ }
246
+ } else {
247
+ return { status: 500, body: { error: 'No database available for tool lookup' }, error: 'No db' };
248
+ }
249
+
250
+ const routing = spec.mcpRouting || {};
251
+ if (!routing.endpoint) {
252
+ return { status: 400, body: { error: `Tool "${toolName}" has no mcpRouting.endpoint` }, error: 'No endpoint' };
253
+ }
254
+
255
+ const baseUrl = (forgeConfig.api?.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
256
+ const path = routing.endpoint;
257
+ const method = (routing.method || 'GET').toUpperCase();
258
+ const paramMap = routing.paramMap || {};
259
+
260
+ // Build URL with path params substituted; collect query and body params
261
+ let url = baseUrl + path;
262
+ const queryParams = new URLSearchParams();
263
+ const bodyObj = {};
264
+
265
+ for (const [toolParam, mapping] of Object.entries(paramMap)) {
266
+ const val = args[toolParam];
267
+ if (val === undefined) continue;
268
+ if (mapping.path) {
269
+ url = url.replace(`{${mapping.path}}`, encodeURIComponent(String(val)));
270
+ } else if (mapping.query) {
271
+ queryParams.set(mapping.query, String(val));
272
+ } else if (mapping.body) {
273
+ bodyObj[mapping.body] = val;
274
+ }
275
+ }
276
+
277
+ if ([...queryParams].length > 0) url += '?' + queryParams.toString();
278
+
279
+ const fetchOpts = {
280
+ method,
281
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
282
+ signal: AbortSignal.timeout(30_000)
283
+ };
284
+
285
+ // Forward user JWT if present
286
+ if (userJwt) {
287
+ fetchOpts.headers['Authorization'] = `Bearer ${userJwt}`;
288
+ }
289
+
290
+ if (['POST', 'PUT', 'PATCH'].includes(method) && Object.keys(bodyObj).length > 0) {
291
+ fetchOpts.body = JSON.stringify(bodyObj);
292
+ }
293
+
294
+ const startMs = Date.now();
295
+ try {
296
+ const res = await fetch(url, fetchOpts);
297
+ const text = await res.text();
298
+ const latencyMs = Date.now() - startMs;
299
+
300
+ let body;
301
+ try { body = JSON.parse(text); } catch { body = { text }; }
302
+
303
+ // Log to MCP call log
304
+ if (db) {
305
+ try {
306
+ insertMcpCallLog(db, {
307
+ tool_name: toolName,
308
+ input_json: JSON.stringify(args),
309
+ output_json: text.slice(0, 10_000),
310
+ status_code: res.status,
311
+ latency_ms: latencyMs,
312
+ error: res.ok ? null : text.slice(0, 500)
313
+ });
314
+ } catch { /* log failure is non-fatal */ }
315
+ }
316
+
317
+ return {
318
+ status: res.status,
319
+ body,
320
+ error: res.ok ? null : `HTTP ${res.status}: ${text.slice(0, 200)}`
321
+ };
322
+ } catch (err) {
323
+ const latencyMs = Date.now() - startMs;
324
+ if (db) {
325
+ try {
326
+ insertMcpCallLog(db, {
327
+ tool_name: toolName,
328
+ input_json: JSON.stringify(args),
329
+ status_code: 0,
330
+ latency_ms: latencyMs,
331
+ error: err.message
332
+ });
333
+ } catch { /* log failure is non-fatal */ }
334
+ }
335
+ return { status: 0, body: { error: err.message }, error: err.message };
336
+ }
337
+ }