@illuma-ai/agents 1.4.0-alpha.4 → 1.4.0-alpha.6

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 (52) hide show
  1. package/dist/cjs/content/ArtifactStore.cjs +579 -0
  2. package/dist/cjs/content/ArtifactStore.cjs.map +1 -0
  3. package/dist/cjs/content/ContentStore.cjs +638 -0
  4. package/dist/cjs/content/ContentStore.cjs.map +1 -0
  5. package/dist/cjs/content/contentAnalyzer.cjs +91 -0
  6. package/dist/cjs/content/contentAnalyzer.cjs.map +1 -0
  7. package/dist/cjs/content/index.cjs +20 -0
  8. package/dist/cjs/content/index.cjs.map +1 -0
  9. package/dist/cjs/content/mcpAutoCache.cjs +115 -0
  10. package/dist/cjs/content/mcpAutoCache.cjs.map +1 -0
  11. package/dist/cjs/main.cjs +10 -0
  12. package/dist/cjs/main.cjs.map +1 -1
  13. package/dist/cjs/providers/tools-server/ToolsServerCapabilityProvider.cjs +4 -1
  14. package/dist/cjs/providers/tools-server/ToolsServerCapabilityProvider.cjs.map +1 -1
  15. package/dist/cjs/tools/proxyTool.cjs +7 -5
  16. package/dist/cjs/tools/proxyTool.cjs.map +1 -1
  17. package/dist/esm/content/ArtifactStore.mjs +576 -0
  18. package/dist/esm/content/ArtifactStore.mjs.map +1 -0
  19. package/dist/esm/content/ContentStore.mjs +635 -0
  20. package/dist/esm/content/ContentStore.mjs.map +1 -0
  21. package/dist/esm/content/contentAnalyzer.mjs +87 -0
  22. package/dist/esm/content/contentAnalyzer.mjs.map +1 -0
  23. package/dist/esm/content/index.mjs +5 -0
  24. package/dist/esm/content/index.mjs.map +1 -0
  25. package/dist/esm/content/mcpAutoCache.mjs +111 -0
  26. package/dist/esm/content/mcpAutoCache.mjs.map +1 -0
  27. package/dist/esm/main.mjs +3 -0
  28. package/dist/esm/main.mjs.map +1 -1
  29. package/dist/esm/providers/tools-server/ToolsServerCapabilityProvider.mjs +4 -1
  30. package/dist/esm/providers/tools-server/ToolsServerCapabilityProvider.mjs.map +1 -1
  31. package/dist/esm/tools/proxyTool.mjs +7 -5
  32. package/dist/esm/tools/proxyTool.mjs.map +1 -1
  33. package/dist/types/content/ArtifactStore.d.ts +223 -0
  34. package/dist/types/content/ContentStore.d.ts +140 -0
  35. package/dist/types/content/contentAnalyzer.d.ts +38 -0
  36. package/dist/types/content/index.d.ts +24 -0
  37. package/dist/types/content/mcpAutoCache.d.ts +89 -0
  38. package/dist/types/content/types.d.ts +75 -0
  39. package/dist/types/index.d.ts +5 -0
  40. package/dist/types/providers/tools-server/ToolsServerCapabilityProvider.d.ts +14 -0
  41. package/dist/types/tools/proxyTool.d.ts +7 -0
  42. package/package.json +6 -1
  43. package/src/content/ArtifactStore.ts +782 -0
  44. package/src/content/ContentStore.ts +753 -0
  45. package/src/content/contentAnalyzer.ts +105 -0
  46. package/src/content/index.ts +51 -0
  47. package/src/content/mcpAutoCache.ts +185 -0
  48. package/src/content/types.ts +82 -0
  49. package/src/index.ts +19 -0
  50. package/src/providers/__tests__/ToolsServerCapabilityProvider.test.ts +65 -0
  51. package/src/providers/tools-server/ToolsServerCapabilityProvider.ts +21 -0
  52. package/src/tools/proxyTool.ts +25 -5
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Utilities for measuring, classifying, and previewing content.
3
+ * Used by the content_tool and MCP auto-caching (Phase 2) to decide
4
+ * when content is "large" and how to summarize it for the LLM.
5
+ */
6
+
7
+ /**
8
+ * Threshold in characters above which content is considered "large"
9
+ * and should be stored in ContentStore rather than inlined.
10
+ * 50K chars ~ 12.5K tokens ~ 6% of 200K context window.
11
+ */
12
+ const LARGE_CONTENT_THRESHOLD = 50_000;
13
+
14
+ /** Content size measurements. */
15
+ export interface ContentMeasurement {
16
+ totalChars: number;
17
+ totalLines: number;
18
+ /** True if content exceeds the large-content threshold. */
19
+ isLarge: boolean;
20
+ }
21
+
22
+ /** Detected content type. */
23
+ export type ContentType = 'json_array' | 'json_object' | 'text' | 'mixed';
24
+
25
+ /**
26
+ * Measure content size and determine if it exceeds the large-content threshold.
27
+ * @param text - The content to measure.
28
+ * @returns Measurement with char count, line count, and large flag.
29
+ */
30
+ export function measureContent(text: string): ContentMeasurement {
31
+ return {
32
+ totalChars: text.length,
33
+ totalLines: text.split('\n').length,
34
+ isLarge: text.length > LARGE_CONTENT_THRESHOLD,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Detect the structural type of content.
40
+ * @param text - The content to classify.
41
+ * @returns The detected type: 'json_array', 'json_object', 'text', or 'mixed'.
42
+ */
43
+ export function detectContentType(text: string): ContentType {
44
+ const trimmed = text.trim();
45
+ if (!trimmed) {
46
+ return 'text';
47
+ }
48
+
49
+ // Fast check: does it look like JSON?
50
+ if (trimmed[0] === '[' || trimmed[0] === '{') {
51
+ try {
52
+ const parsed = JSON.parse(trimmed);
53
+ if (Array.isArray(parsed)) {
54
+ return 'json_array';
55
+ }
56
+ if (typeof parsed === 'object' && parsed !== null) {
57
+ return 'json_object';
58
+ }
59
+ } catch {
60
+ // Not valid JSON — might be mixed content
61
+ if (trimmed[0] === '[' || trimmed[0] === '{') {
62
+ return 'mixed';
63
+ }
64
+ }
65
+ }
66
+
67
+ return 'text';
68
+ }
69
+
70
+ /**
71
+ * Generate a preview/summary of content for the LLM context.
72
+ * For JSON arrays, shows the first N items. For text, truncates with an ellipsis.
73
+ *
74
+ * @param text - The full content to preview.
75
+ * @param opts - Options controlling preview size.
76
+ * @returns A truncated preview string.
77
+ */
78
+ export function generatePreview(
79
+ text: string,
80
+ opts?: { maxItems?: number; maxChars?: number }
81
+ ): string {
82
+ const maxItems = opts?.maxItems ?? 5;
83
+ const maxChars = opts?.maxChars ?? 2048;
84
+ const contentType = detectContentType(text);
85
+
86
+ if (contentType === 'json_array') {
87
+ try {
88
+ const arr = JSON.parse(text.trim()) as unknown[];
89
+ if (arr.length <= maxItems) {
90
+ return text.trim();
91
+ }
92
+ const preview = arr.slice(0, maxItems);
93
+ const result = JSON.stringify(preview, null, 2);
94
+ return `${result}\n... (${arr.length - maxItems} more items, ${arr.length} total)`;
95
+ } catch {
96
+ // Fall through to text truncation
97
+ }
98
+ }
99
+
100
+ if (text.length <= maxChars) {
101
+ return text;
102
+ }
103
+
104
+ return `${text.substring(0, maxChars)}\n... (truncated, ${text.length} chars total)`;
105
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @illuma-ai/agents/content — per-conversation content + artifact stores.
3
+ *
4
+ * Host-agnostic primitives for keeping large tool / agent output out of
5
+ * the LLM context window:
6
+ *
7
+ * - {@link ContentStore} — ephemeral per-conversation cache (backed by
8
+ * any caller-provided {@link Keyv} instance; recommended with
9
+ * @keyv/redis for multi-instance deployments).
10
+ * - {@link ArtifactStore} — extends {@link ContentStore} with
11
+ * durable persistence via caller-provided {@link S3Strategy} and
12
+ * {@link FileModel} adapters.
13
+ * - {@link interceptMcpResult} — MCP tool-result auto-caching with
14
+ * gate semantics (no-op when the agent can't dereference
15
+ * `content_id`s).
16
+ * - {@link measureContent} / {@link detectContentType} /
17
+ * {@link generatePreview} — content classifiers shared by
18
+ * consumers that need to decide when to store vs inline.
19
+ */
20
+
21
+ export { ContentStore, CONTENT_TTL_MS } from './ContentStore';
22
+ export {
23
+ ArtifactStore,
24
+ sanitizeName,
25
+ type S3Strategy,
26
+ type FileModel,
27
+ type Logger,
28
+ } from './ArtifactStore';
29
+ export {
30
+ measureContent,
31
+ detectContentType,
32
+ generatePreview,
33
+ type ContentMeasurement,
34
+ type ContentType,
35
+ } from './contentAnalyzer';
36
+ export type {
37
+ StoreEntry,
38
+ StoredEntry,
39
+ ContentMetadata,
40
+ ReadResult,
41
+ ReadAllResult,
42
+ SearchMatch,
43
+ EditResult,
44
+ } from './types';
45
+ export {
46
+ interceptMcpResult,
47
+ extractUiMarkers,
48
+ buildCachedResponse,
49
+ type AutoCacheContext,
50
+ type AutoCacheResult,
51
+ } from './mcpAutoCache';
@@ -0,0 +1,185 @@
1
+ /**
2
+ * MCP Auto-Caching Interceptor
3
+ *
4
+ * When an MCP tool returns a large text result (>50K chars / ~12.5K tokens),
5
+ * stores it in the caller-provided {@link ContentStore} and returns a
6
+ * compact metadata reference. The LLM then uses a `content_reader` tool
7
+ * (read/search/list/info) to pull relevant pieces of the stored result
8
+ * without burning tokens on the full payload.
9
+ *
10
+ * Gate: callers MUST pass `contentReaderEnabled: true` on the context —
11
+ * otherwise the interceptor returns the original text unchanged, because
12
+ * caching without a reader tool leaves the agent with a content_id it
13
+ * cannot dereference.
14
+ *
15
+ * Design:
16
+ * - Only text content is cached. Images and UI resources pass through.
17
+ * - UI resource markers (\ui{...}) are preserved in the returned text.
18
+ * - Artifacts (second element of the tuple) are never modified.
19
+ * - Cached response is a compact one-liner (~30 tokens) — no preview blob.
20
+ * - If the store write fails, degrades gracefully — returns original text.
21
+ */
22
+
23
+ import { ContentStore } from './ContentStore';
24
+ import { measureContent, detectContentType } from './contentAnalyzer';
25
+ import type { Logger } from './ArtifactStore';
26
+ import type { ContentMeasurement } from './contentAnalyzer';
27
+
28
+ /** Context for the auto-cache interceptor. */
29
+ export interface AutoCacheContext {
30
+ /**
31
+ * Pre-constructed {@link ContentStore} instance scoped to the current
32
+ * conversation. Caller owns the underlying cache lifecycle.
33
+ */
34
+ store: ContentStore;
35
+ /** MCP server name (e.g. "sharepoint", "github"). */
36
+ serverName: string;
37
+ /** MCP tool name (e.g. "read_file", "search_code"). */
38
+ toolName: string;
39
+ /**
40
+ * Whether the current agent has `content_reader` available. When false,
41
+ * the interceptor passes the large text through unchanged — caching
42
+ * without a reader tool leaves the agent with a content_id it cannot
43
+ * dereference, which is worse than returning the raw text.
44
+ */
45
+ contentReaderEnabled: boolean;
46
+ /**
47
+ * Optional diagnostic echo. Typically the conversation ID so operators
48
+ * can correlate the log line with upstream traces.
49
+ */
50
+ conversationId?: string;
51
+ /** Optional logger; defaults to silence. */
52
+ logger?: Logger;
53
+ }
54
+
55
+ /** Result of the auto-cache interception. */
56
+ export interface AutoCacheResult {
57
+ /** The (possibly modified) text content to return to the LLM. */
58
+ text: string;
59
+ /** Whether the content was cached. */
60
+ cached: boolean;
61
+ /** The content_id if cached. */
62
+ contentId?: string;
63
+ /** Content measurement data. */
64
+ measurement?: ContentMeasurement;
65
+ }
66
+
67
+ /**
68
+ * Regex to detect UI resource markers: \ui{...}
69
+ * These MUST be preserved in the returned text even after caching.
70
+ */
71
+ const UI_MARKER_REGEX = /\\ui\{[^}]+\}/g;
72
+
73
+ /**
74
+ * Extract all UI resource markers from text.
75
+ * @param text - The text to scan.
76
+ * @returns Array of marker strings (e.g. ['\\ui{abc123}', '\\ui{def456}'])
77
+ */
78
+ export function extractUiMarkers(text: string): string[] {
79
+ return text.match(UI_MARKER_REGEX) ?? [];
80
+ }
81
+
82
+ /**
83
+ * Build a compact metadata reference for the cached content.
84
+ * Keeps token usage minimal (~30 tokens) while giving the LLM all it needs
85
+ * to access the data via content_tool.
86
+ *
87
+ * @param contentId - ContentStore entry ID.
88
+ * @param measurement - Size data.
89
+ * @param toolName - The MCP tool that produced the result.
90
+ * @param uiMarkers - UI markers extracted from the original text.
91
+ */
92
+ export function buildCachedResponse(
93
+ contentId: string,
94
+ measurement: ContentMeasurement,
95
+ toolName: string,
96
+ uiMarkers: string[]
97
+ ): string {
98
+ const sizeKB = (measurement.totalChars / 1024).toFixed(0);
99
+ let response = `[Stored: ${toolName} result | ${sizeKB}KB | ${measurement.totalLines} lines | content_id: ${contentId}]\nUse content_reader (action: read or search) with this content_id to access the full result — do NOT re-run the MCP tool.`;
100
+
101
+ if (uiMarkers.length > 0) {
102
+ response += '\n\n' + uiMarkers.join('\n');
103
+ }
104
+
105
+ return response;
106
+ }
107
+
108
+ /**
109
+ * Core auto-cache interceptor for MCP tool results.
110
+ *
111
+ * If the text exceeds the large-content threshold (50K chars), stores it
112
+ * in ContentStore and returns a preview + content_id. Otherwise passes through.
113
+ *
114
+ * @param text - The text content from the MCP tool result.
115
+ * @param context - MCP tool and conversation context.
116
+ * @returns AutoCacheResult with possibly-modified text and caching metadata.
117
+ */
118
+ export async function interceptMcpResult(
119
+ text: string,
120
+ context: AutoCacheContext
121
+ ): Promise<AutoCacheResult> {
122
+ const measurement = measureContent(text);
123
+ const log = context.logger;
124
+
125
+ if (!measurement.isLarge) {
126
+ return { text, cached: false, measurement };
127
+ }
128
+
129
+ // Gate: caching only makes sense when the agent can read back the stub.
130
+ // If content_reader is disabled, returning a content_id the agent can't
131
+ // dereference is strictly worse than returning the full text — the model
132
+ // would either hallucinate tool calls or flag the result as inaccessible.
133
+ if (!context.contentReaderEnabled) {
134
+ log?.debug(
135
+ `[MCP Auto-Cache] Skipped caching for ${context.serverName}:${context.toolName} — content_reader disabled on this agent`,
136
+ {
137
+ totalChars: measurement.totalChars,
138
+ totalLines: measurement.totalLines,
139
+ conversationId: context.conversationId,
140
+ }
141
+ );
142
+ return { text, cached: false, measurement };
143
+ }
144
+
145
+ try {
146
+ const contentType = detectContentType(text);
147
+
148
+ const contentId = await context.store.store({
149
+ name: `${context.toolName} result`,
150
+ type: contentType === 'text' ? 'text/plain' : 'application/json',
151
+ content: text,
152
+ source: `mcp:${context.serverName}`,
153
+ });
154
+
155
+ const uiMarkers = extractUiMarkers(text);
156
+
157
+ const replacementText = buildCachedResponse(
158
+ contentId,
159
+ measurement,
160
+ context.toolName,
161
+ uiMarkers
162
+ );
163
+
164
+ log?.debug(
165
+ `[MCP Auto-Cache] Cached large result from ${context.serverName}:${context.toolName}`,
166
+ {
167
+ contentId,
168
+ totalChars: measurement.totalChars,
169
+ totalLines: measurement.totalLines,
170
+ conversationId: context.conversationId,
171
+ contentType,
172
+ uiMarkersPreserved: uiMarkers.length,
173
+ }
174
+ );
175
+
176
+ return { text: replacementText, cached: true, contentId, measurement };
177
+ } catch (error) {
178
+ // PERF: If caching fails, fall through silently — full content goes to LLM
179
+ log?.warn(
180
+ `[MCP Auto-Cache] Failed to cache result from ${context.serverName}:${context.toolName}, passing through`,
181
+ { error: (error as Error).message }
182
+ );
183
+ return { text, cached: false, measurement };
184
+ }
185
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Types for the per-conversation content store.
3
+ * Content entries are ephemeral (Redis-backed, 5 min TTL) and used to keep
4
+ * large tool results out of the LLM context window.
5
+ */
6
+
7
+ /** Input when storing new content. */
8
+ export interface StoreEntry {
9
+ /** Human-readable name (e.g. "Q1 Sales Report.xlsx") */
10
+ name: string;
11
+ /** MIME-like type: "text/plain", "application/json", "mcp_response", "artifact" */
12
+ type: string;
13
+ /** The raw content string */
14
+ content: string;
15
+ /** Origin identifier: "mcp:sharepoint", "artifact:msg123", "agent", etc. */
16
+ source: string;
17
+ /** Arbitrary extra data attached to the entry */
18
+ metadata?: Record<string, unknown>;
19
+ }
20
+
21
+ /** Metadata returned by info() and list() — content is NOT included. */
22
+ export interface ContentMetadata {
23
+ id: string;
24
+ name: string;
25
+ type: string;
26
+ source: string;
27
+ totalLines: number;
28
+ totalChars: number;
29
+ createdAt: number;
30
+ /** MongoDB File.file_id after persistence to S3 (set by ArtifactStore) */
31
+ fileId?: string;
32
+ /** Owner user ID (set by ArtifactStore for S3 path construction) */
33
+ userId?: string;
34
+ /** Conversation scope (set by ArtifactStore for S3 path construction) */
35
+ conversationId?: string;
36
+ /** True when the file exists in MongoDB but hasn't been ingested into ContentStore yet */
37
+ needsIngestion?: boolean;
38
+ }
39
+
40
+ /** Result of a readLines() call. */
41
+ export interface ReadResult {
42
+ /** Formatted content with line numbers */
43
+ content: string;
44
+ startLine: number;
45
+ endLine: number;
46
+ totalLines: number;
47
+ totalChars: number;
48
+ /** True if there are more lines beyond endLine */
49
+ truncated: boolean;
50
+ }
51
+
52
+ /** Result of a readAll() call — raw content without line-number formatting. */
53
+ export interface ReadAllResult {
54
+ /** Raw content string (no line-number prefixes) */
55
+ content: string;
56
+ totalLines: number;
57
+ totalChars: number;
58
+ }
59
+
60
+ /** A single search match within content. */
61
+ export interface SearchMatch {
62
+ lineNumber: number;
63
+ content: string;
64
+ }
65
+
66
+ /** Result of a strReplace() edit. */
67
+ export interface EditResult {
68
+ success: boolean;
69
+ /** Human-readable diff snippet */
70
+ diff: string;
71
+ /** Line number where the replacement occurred */
72
+ lineNumber: number;
73
+ /** Number of lines affected by the edit */
74
+ linesAffected: number;
75
+ error?: string;
76
+ }
77
+
78
+ /** Internal shape stored in Redis for each content entry. */
79
+ export interface StoredEntry {
80
+ content: string;
81
+ metadata: ContentMetadata;
82
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,25 @@ export * from './tools/proxyTool';
33
33
  /* Capability Providers */
34
34
  export * from './providers';
35
35
 
36
+ /* Content / artifact stores.
37
+ * Prefer the subpath import `@illuma-ai/agents/content` — this barrel
38
+ * only re-exports the store classes to avoid symbol collisions with
39
+ * existing top-level exports (e.g., Logger from tools/search,
40
+ * ContentType from elsewhere). */
41
+ export { ContentStore, CONTENT_TTL_MS } from './content/ContentStore';
42
+ export { ArtifactStore, sanitizeName } from './content/ArtifactStore';
43
+ export type {
44
+ S3Strategy,
45
+ FileModel,
46
+ Logger as ContentLogger,
47
+ } from './content/ArtifactStore';
48
+ export {
49
+ interceptMcpResult,
50
+ extractUiMarkers,
51
+ buildCachedResponse,
52
+ } from './content/mcpAutoCache';
53
+ export type { AutoCacheContext, AutoCacheResult } from './content/mcpAutoCache';
54
+
36
55
  /* Memory (storage + factory) */
37
56
  export * from './memory';
38
57
 
@@ -203,4 +203,69 @@ describe('ToolsServerCapabilityProvider.createRunnables', () => {
203
203
  /missing API key/
204
204
  );
205
205
  });
206
+
207
+ it('getExecuteAuthHeaders is invoked per call and forwarded as HTTP headers', async () => {
208
+ const client = {
209
+ get: jest.fn().mockResolvedValue({ status: 200, data: manifestFixture }),
210
+ post: jest.fn().mockResolvedValue({
211
+ status: 200,
212
+ data: {
213
+ success: true,
214
+ result: 'ok',
215
+ timing: { durationMs: 1 },
216
+ },
217
+ }),
218
+ defaults: { baseURL: 'http://stub', headers: {} },
219
+ };
220
+
221
+ let mintCount = 0;
222
+ const p = new ToolsServerCapabilityProvider({
223
+ baseUrl: 'http://x',
224
+ apiKey: 'k',
225
+ client: client as unknown as ReturnType<typeof axios.create>,
226
+ getExecuteAuthHeaders: async () => {
227
+ mintCount += 1;
228
+ return { Authorization: `Bearer TOKEN-${mintCount}` };
229
+ },
230
+ });
231
+ const caps = await p.fetchManifest();
232
+ const [wikipedia] = await p.createRunnables(
233
+ caps.filter((c) => c.name === 'wikipedia'),
234
+ {}
235
+ );
236
+
237
+ await wikipedia.invoke({ query: 'a' });
238
+ await wikipedia.invoke({ query: 'b' });
239
+
240
+ // Two invocations → two mints (fresh token each call)
241
+ expect(mintCount).toBe(2);
242
+
243
+ // Each POST includes the Authorization header from the mint
244
+ const firstCall = (client.post as jest.Mock).mock.calls[0];
245
+ const secondCall = (client.post as jest.Mock).mock.calls[1];
246
+ expect(firstCall[2]?.headers).toEqual({ Authorization: 'Bearer TOKEN-1' });
247
+ expect(secondCall[2]?.headers).toEqual({ Authorization: 'Bearer TOKEN-2' });
248
+ });
249
+
250
+ it('getExecuteAuthHeaders is NOT called during manifest fetch (service-to-service)', async () => {
251
+ const client = {
252
+ get: jest.fn().mockResolvedValue({ status: 200, data: manifestFixture }),
253
+ post: jest.fn(),
254
+ defaults: { baseURL: 'http://stub', headers: {} },
255
+ };
256
+
257
+ const authBuilder = jest
258
+ .fn()
259
+ .mockReturnValue({ Authorization: 'Bearer X' });
260
+ const p = new ToolsServerCapabilityProvider({
261
+ baseUrl: 'http://x',
262
+ apiKey: 'k',
263
+ client: client as unknown as ReturnType<typeof axios.create>,
264
+ getExecuteAuthHeaders: authBuilder,
265
+ });
266
+
267
+ await p.fetchManifest();
268
+ // Manifest fetch is service-to-service — no per-user auth headers
269
+ expect(authBuilder).not.toHaveBeenCalled();
270
+ });
206
271
  });
@@ -48,6 +48,21 @@ export interface ToolsServerConfig {
48
48
  client?: AxiosInstance;
49
49
  /** Optional proxy override (defaults to process.env.PROXY). */
50
50
  proxy?: string | null;
51
+ /**
52
+ * Optional per-request auth header builder — invoked on every tool
53
+ * invocation (NOT on the manifest fetch, which is service-to-service).
54
+ * When provided, the returned headers are merged into the `/execute`
55
+ * request so the host can pass user-scoped identity (e.g.,
56
+ * `Authorization: Bearer <jwt>`) that tools-server verifies for
57
+ * admin-gated tools.
58
+ *
59
+ * Typical host wiring: mint a short-lived JWT per call carrying the
60
+ * authenticated user's `{ userId, role }` claims; tools-server's
61
+ * `TOOLS_SERVER_JWT_SECRET` validates.
62
+ */
63
+ getExecuteAuthHeaders?: () =>
64
+ | Record<string, string>
65
+ | Promise<Record<string, string>>;
51
66
  }
52
67
 
53
68
  /**
@@ -91,6 +106,9 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
91
106
  private readonly manifestPath: string;
92
107
  private readonly executePath: string;
93
108
  private readonly cache: ManifestCache;
109
+ private readonly getExecuteAuthHeaders?: () =>
110
+ | Record<string, string>
111
+ | Promise<Record<string, string>>;
94
112
 
95
113
  constructor(private readonly config: ToolsServerConfig) {
96
114
  const {
@@ -102,6 +120,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
102
120
  manifestTtlMs = 60_000,
103
121
  client,
104
122
  proxy,
123
+ getExecuteAuthHeaders,
105
124
  } = config;
106
125
 
107
126
  if (!baseUrl) {
@@ -115,6 +134,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
115
134
  this.manifestPath = manifestPath;
116
135
  this.executePath = executePath;
117
136
  this.cache = new ManifestCache({ ttlMs: manifestTtlMs });
137
+ this.getExecuteAuthHeaders = getExecuteAuthHeaders;
118
138
 
119
139
  if (client) {
120
140
  this.client = client;
@@ -184,6 +204,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
184
204
  buildProxyTool(cap, credentials, {
185
205
  client: this.client,
186
206
  executePath: this.executePath,
207
+ getAuthHeaders: this.getExecuteAuthHeaders,
187
208
  })
188
209
  );
189
210
  }
@@ -40,6 +40,15 @@ export interface ProxyToolOptions {
40
40
  * telemetry, debug logging. Errors in the hook are swallowed.
41
41
  */
42
42
  onExecute?: (ctx: ExecuteCallbackContext) => void;
43
+ /**
44
+ * Optional per-invocation auth header builder. Called on every tool
45
+ * invocation before POSTing; returned headers are merged into the
46
+ * request alongside the base client's headers. Typical use: pass a
47
+ * freshly minted per-user JWT for admin-gated tools.
48
+ */
49
+ getAuthHeaders?: () =>
50
+ | Record<string, string>
51
+ | Promise<Record<string, string>>;
43
52
  }
44
53
 
45
54
  export interface ExecuteCallbackContext {
@@ -61,7 +70,12 @@ export function buildProxyTool(
61
70
  credentials: CredentialMap,
62
71
  options: ProxyToolOptions
63
72
  ): StructuredToolInterface {
64
- const { client, executePath = '/execute/:name', onExecute } = options;
73
+ const {
74
+ client,
75
+ executePath = '/execute/:name',
76
+ onExecute,
77
+ getAuthHeaders,
78
+ } = options;
65
79
  const url = executePath.replace(':name', encodeURIComponent(capability.name));
66
80
 
67
81
  return tool(
@@ -76,10 +90,16 @@ export function buildProxyTool(
76
90
  `${debugPrefix} invoking — inputKeys=${input && typeof input === 'object' ? Object.keys(input as object).length : 0}`
77
91
  );
78
92
 
79
- const res = await client.post<ExecuteResponse>(url, {
80
- input,
81
- credentials,
82
- });
93
+ const extraHeaders = getAuthHeaders
94
+ ? await getAuthHeaders()
95
+ : undefined;
96
+ const res = await client.post<ExecuteResponse>(
97
+ url,
98
+ { input, credentials },
99
+ extraHeaders && Object.keys(extraHeaders).length > 0
100
+ ? { headers: extraHeaders }
101
+ : undefined
102
+ );
83
103
 
84
104
  const durationMs = Date.now() - startMs;
85
105