Compare commits

..

10 Commits

Author SHA1 Message Date
jkunz 2b9a17c882 v3.7.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-18 08:53:59 +00:00
jkunz 9957485281 feat(mcp): add MCP tool integration via dedicated subpath export 2026-05-18 08:53:57 +00:00
jkunz cf45add905 v3.6.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-15 08:03:17 +00:00
jkunz 7b4184e88d feat(readme): document provider prompt caching behavior and SmartAI cache integration 2026-05-15 08:03:15 +00:00
jkunz 01df877480 v3.5.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-15 05:38:50 +00:00
jkunz f138495edf feat(tools): add reusable execution contexts for shell, filesystem, and browser tools 2026-05-15 05:38:35 +00:00
jkunz f183bf19ac v3.4.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 22:44:10 +00:00
jkunz 6fb2b3a61f feat(agent): add streamed reasoning summary callbacks to runAgent 2026-05-14 22:44:08 +00:00
jkunz ca56f4c4e8 v3.3.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 16:50:16 +00:00
jkunz 5ceeddd8bb feat(deps): upgrade @push.rocks/smartai to ^4.0.0 2026-05-14 16:50:08 +00:00
18 changed files with 1832 additions and 115 deletions
+49
View File
@@ -4,6 +4,55 @@
## 2026-05-18 - 3.7.0
### Features
- add MCP tool integration via dedicated subpath export (mcp)
- introduces a new @push.rocks/smartagent/mcp entrypoint with MCP client, stdio, HTTP, and custom transport support
- adds MCP tool discovery, name sanitization, multi-server prefixing, result formatting, and cleanup handling
- documents MCP usage in the README and adds test coverage for tool exposure, pagination, and result formatting
## 2026-05-15 - 3.6.0
### Features
- document provider prompt caching behavior and SmartAI cache integration (readme)
- Adds provider prompt caching to the feature list in the README
- Clarifies that provider-specific cache metadata is centralized in @push.rocks/smartai
## 2026-05-15 - 3.5.0
### Features
- add reusable execution contexts for shell, filesystem, and browser tools (tools)
- introduces shared tool execution context interfaces plus a local Node.js context implementation
- adds createShellTools, createFilesystemTools, and createBrowserTools for host-provided transports with permission hooks
- re-exports the new tool factories and context types from the main package entrypoint while keeping compatibility wrappers
## 2026-05-14 - 3.4.0
### Features
- add streamed reasoning summary callbacks to runAgent (agent)
- Introduces onReasoningStart, onReasoningDelta, and onReasoningEnd callbacks in the agent options interface
- Handles reasoning-start, reasoning-delta, and reasoning-end stream chunks while accumulating reasoning text by id
- Ensures incomplete reasoning streams are finalized after the response completes
- Adds tests for reasoning summary streaming and updates the README API documentation
## 2026-05-14 - 3.3.0
### Features
- upgrade @push.rocks/smartai to ^4.0.0 (deps)
- Updates the core smartai dependency from ^2.3.0 to ^4.0.0.
- Refreshes README hints to document the new smartai version.
## 2026-05-14 - 3.2.0
### Features
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartagent",
"version": "3.2.0",
"version": "3.7.0",
"private": false,
"description": "Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.",
"main": "dist_ts/index.js",
@@ -18,6 +18,10 @@
"./compaction": {
"import": "./dist_ts_compaction/index.js",
"types": "./dist_ts_compaction/index.d.ts"
},
"./mcp": {
"import": "./dist_ts_mcp/index.js",
"types": "./dist_ts_mcp/index.d.ts"
}
},
"author": "Task Venture Capital GmbH",
@@ -37,7 +41,8 @@
"@types/node": "^25.6.0"
},
"dependencies": {
"@push.rocks/smartai": "^2.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@push.rocks/smartai": "^4.0.0",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartshell": "^3.3.8",
@@ -57,6 +62,7 @@
"ts/**/*",
"ts_tools/**/*",
"ts_compaction/**/*",
"ts_mcp/**/*",
"dist/**/*",
"dist_*/**/*",
"assets/**/*",
+566 -5
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ Each exports a factory returning a flat `ToolSet` (Record<string, Tool>):
4. **jsonTool()**`json_validate`, `json_transform`
## Key Dependencies
- `@push.rocks/smartai` ^2.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
- `@push.rocks/smartai` ^4.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
- `ai` ^6.0.0 — Vercel AI SDK v6 (`streamText`, `stepCountIs`, `ModelMessage`, `ToolSet`)
- `zod` ^3.25.0 — tool input schema definitions
- `@push.rocks/smartfs`, `smartshell`, `smartrequest` — tool implementations
+70 -1
View File
@@ -76,7 +76,8 @@ console.log(result.usage); // { inputTokens, outputTokens, totalTokens, cacheR
-**Parallel tool execution** — multiple tool calls in a single step are executed concurrently
- 🔧 **Auto-retry with backoff** — handles 429/529/503 errors with header-aware retry delays
- 🩹 **Tool call repair** — case-insensitive name matching + invalid tool sink prevents crashes
- 📊 **Token streaming**`onToken` and `onToolCall` callbacks for real-time progress
- 💰 **Provider prompt caching** — uses SmartAI cache helpers for Anthropic breakpoints and OpenAI cache affinity
- 📊 **Token and reasoning streaming**`onToken`, `onReasoning*`, and `onToolCall` callbacks for real-time progress
- 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback
## Core API
@@ -98,6 +99,9 @@ The single entry point. Options:
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
| `onReasoningStart` | `(id: string) => void` | — | Called when a reasoning summary starts |
| `onReasoningDelta` | `(id: string, delta: string) => void` | — | Called for streamed reasoning summary text |
| `onReasoningEnd` | `(id: string, text: string) => void` | — | Called when a reasoning summary completes |
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
| `onToolResult` | `(name: string, result: unknown) => void` | — | Called when a tool finishes |
| `validateCompletion` | `(result) => string \| void` | — | Return a string to reject and reprompt an incomplete run |
@@ -165,6 +169,7 @@ const saved = result.toolCalls.some((call) =>
SmartAgent enables prompt-cache defaults by default:
- Cache behavior is provided by `@push.rocks/smartai`, so provider-specific cache metadata stays centralized there.
- Anthropic-compatible models get cache breakpoints on the first two system messages and the two most recent non-system messages.
- OpenAI models get `store: false` by default and, when `sessionId` is provided, `promptCacheKey: sessionId` with `promptCacheRetention: 'in_memory'`.
- Longer retention is opt-in. Use `cache: { retention: '24h' }` for OpenAI or `cache: { retention: '1h' }` for Anthropic.
@@ -239,6 +244,70 @@ await runAgent({
});
```
## MCP Tools
MCP support lives in the `@push.rocks/smartagent/mcp` subpath. The main `@push.rocks/smartagent` import stays unchanged unless you opt in.
```typescript
import { runAgent } from '@push.rocks/smartagent';
import { createMcpTools } from '@push.rocks/smartagent/mcp';
const mcp = await createMcpTools({
servers: {
filesystem: {
type: 'stdio',
command: 'my-mcp-filesystem-server',
args: ['/workspace/project'],
},
},
});
try {
const result = await runAgent({
model,
prompt: 'Use the available MCP tools to inspect the project.',
tools: mcp.tools,
maxSteps: 10,
});
console.log(result.text);
} finally {
await mcp.close();
}
```
`createMcpTools()` supports `stdio`, Streamable HTTP, custom transports, and pre-created MCP clients. When multiple servers are configured, exposed tool names are prefixed with the sanitized server name, for example `filesystem__read_file`. The returned `toolNameMap` maps exposed AI SDK tool names back to their original MCP server/tool names.
## Reusable Tool Contexts
SmartAgent can build tools once and execute them through a host-provided context. The same shell, filesystem, and browser tool schemas can target local Node.js, SSH, MCP, or another transport supplied by the host app.
```typescript
import { createFilesystemTools, createShellTools, type IToolExecutionContext } from '@push.rocks/smartagent';
const context: IToolExecutionContext = {
cwd: '/workspace/project',
requestPermission: async (request) => {
// Host app decides whether to allow writes, commands, browser actions, etc.
},
shell: {
run: async (command, options) => sshRun(command, options),
},
fs: {
readFile: async (path, options) => sshRead(path, options),
writeFile: async (path, content) => sshWrite(path, content),
listDirectory: async (path, options) => sshList(path, options),
},
};
const tools = {
...createShellTools(context),
...createFilesystemTools(context, { includeDelete: false }),
};
```
For local execution, use `createLocalToolExecutionContext()` or the compatibility wrappers `shellTool()` and `filesystemTool()`.
## ToolRegistry
A lightweight helper for collecting tools:
+143
View File
@@ -0,0 +1,143 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import {
createMcpTools,
formatMcpToolResult,
sanitizeMcpToolName,
type IMcpClientLike,
} from '../ts_mcp/index.js';
interface IFakeMcpClientState {
calls: Array<{ name: string; arguments?: Record<string, unknown> }>;
closed: boolean;
}
const createFakeMcpClient = (
toolName: string,
responsePrefix: string,
state: IFakeMcpClientState,
): IMcpClientLike => ({
listTools: async () => ({
tools: [
{
name: toolName,
description: `Runs ${toolName}`,
inputSchema: {
type: 'object',
properties: {
value: { type: 'string' },
},
required: ['value'],
},
},
],
} as any),
callTool: async (params) => {
state.calls.push(params);
return {
content: [
{
type: 'text',
text: `${responsePrefix}: ${String(params.arguments?.value ?? '')}`,
},
],
structuredContent: {
ok: true,
},
} as any;
},
close: async () => {
state.closed = true;
},
});
tap.test('sanitizeMcpToolName should produce AI SDK-safe tool names', async () => {
expect(sanitizeMcpToolName('read file/path')).toEqual('read_file_path');
expect(sanitizeMcpToolName('123-start')).toEqual('_123-start');
expect(sanitizeMcpToolName('***', 'fallback')).toEqual('fallback');
});
tap.test('createMcpTools should expose prefixed tools for multiple MCP servers', async () => {
const firstState: IFakeMcpClientState = { calls: [], closed: false };
const secondState: IFakeMcpClientState = { calls: [], closed: false };
const mcpTools = await createMcpTools({
servers: {
'server one': {
type: 'client',
client: createFakeMcpClient('echo tool', 'first', firstState),
},
'server/two': {
type: 'client',
client: createFakeMcpClient('echo tool', 'second', secondState),
},
},
});
expect(Object.keys(mcpTools.tools).sort()).toEqual([
'server_one__echo_tool',
'server_two__echo_tool',
]);
expect(mcpTools.toolNameMap.server_one__echo_tool).toEqual({
serverName: 'server one',
toolName: 'echo tool',
});
const output = await (mcpTools.tools.server_one__echo_tool as any).execute({ value: 'hello' });
expect(output).toInclude('first: hello');
expect(output).toInclude('Structured content');
expect(firstState.calls).toEqual([
{
name: 'echo tool',
arguments: { value: 'hello' },
},
]);
await mcpTools.close();
expect(firstState.closed).toBeTrue();
expect(secondState.closed).toBeTrue();
});
tap.test('createMcpTools should support paginated tool discovery and custom formatting', async () => {
const calls: Array<{ name: string; arguments?: Record<string, unknown> }> = [];
const client: IMcpClientLike = {
listTools: async (params) => ({
tools: [
{
name: params?.cursor ? 'second tool' : 'first tool',
inputSchema: { type: 'object' },
},
],
nextCursor: params?.cursor ? undefined : 'next',
} as any),
callTool: async (params) => {
calls.push(params);
return { content: [{ type: 'text', text: 'ok' }] } as any;
},
};
const mcpTools = await createMcpTools({
servers: {
only: { type: 'client', client },
},
formatResult: (_result, context) => context.exposedToolName,
});
expect(Object.keys(mcpTools.tools).sort()).toEqual(['first_tool', 'second_tool']);
const output = await (mcpTools.tools.second_tool as any).execute('ignored');
expect(output).toEqual('second_tool');
expect(calls).toEqual([
{
name: 'second tool',
arguments: {},
},
]);
});
tap.test('formatMcpToolResult should handle MCP result shapes', async () => {
expect(formatMcpToolResult({ toolResult: { ok: true } } as any)).toEqual({ ok: true });
expect(formatMcpToolResult({ content: [{ type: 'text', text: 'failed' }], isError: true } as any)).toInclude(
'MCP tool returned an error',
);
});
export default tap.start();
+126 -1
View File
@@ -1,7 +1,7 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
import * as smartagent from '../ts/index.js';
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
import { createBrowserTools, createFilesystemTools, createShellTools, filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
import { compactMessages } from '../ts_compaction/index.js';
const createUsage = (inputTokens: number, outputTokens: number) => ({
@@ -33,6 +33,25 @@ const createTextStreamResult = (text: string) => ({
] as any[]),
});
const createReasoningStreamResult = (reasoning: string, text: string) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{ type: 'reasoning-start', id: 'reasoning-1' },
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(0, 7) },
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(7) },
{ type: 'reasoning-end', id: 'reasoning-1' },
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: text },
{ type: 'text-end', id: 'text-1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: createUsage(2, 2),
},
] as any[]),
});
const createToolCallStreamResult = (toolName: string, input: unknown) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
@@ -131,6 +150,32 @@ tap.test('runAgent should add OpenAI cache defaults when sessionId is provided',
expect(openaiOptions.reasoningEffort).toEqual('high');
});
tap.test('runAgent should stream reasoning summary callbacks', async () => {
const reasoningEvents: string[] = [];
const tokenDeltas: string[] = [];
const model = new MockLanguageModelV3({
doStream: async () => createReasoningStreamResult('thinking through it', 'done') as any,
});
const result = await smartagent.runAgent({
model,
prompt: 'hello',
onToken: (delta) => tokenDeltas.push(delta),
onReasoningStart: (id) => reasoningEvents.push('start:' + id),
onReasoningDelta: (id, delta) => reasoningEvents.push('delta:' + id + ':' + delta),
onReasoningEnd: (id, text) => reasoningEvents.push('end:' + id + ':' + text),
});
expect(result.text).toEqual('done');
expect(tokenDeltas.join('')).toEqual('done');
expect(reasoningEvents).toEqual([
'start:reasoning-1',
'delta:reasoning-1:thinkin',
'delta:reasoning-1:g through it',
'end:reasoning-1:thinking through it',
]);
});
tap.test('runAgent should mark Anthropic prompt cache breakpoints by default', async () => {
const model = new MockLanguageModelV3({
provider: 'anthropic',
@@ -312,6 +357,29 @@ tap.test('shellTool returns expected tool names', async () => {
expect(names).toContain('run_command');
});
tap.test('createShellTools should execute through supplied context', async () => {
const permissions: unknown[] = [];
const calls: unknown[] = [];
const tools = createShellTools({
cwd: '/workspace',
requestPermission: async (request) => {
permissions.push(request);
},
shell: {
run: async (command, options) => {
calls.push({ command, options });
return { exitCode: 0, stdout: 'context-output', stderr: '' };
},
},
});
const result = await (tools.run_command as any).execute({ command: 'echo test', timeoutMs: 1234 });
expect(result).toEqual('context-output');
expect(calls[0]).toEqual({ command: 'echo test', options: { cwd: '/workspace', timeoutMs: 1234, abortSignal: undefined } });
expect(JSON.stringify(permissions[0])).toInclude('echo test');
});
tap.test('httpTool returns expected tool names', async () => {
const tools = httpTool();
const names = Object.keys(tools);
@@ -326,6 +394,63 @@ tap.test('jsonTool returns expected tool names', async () => {
expect(names).toContain('json_transform');
});
tap.test('createFilesystemTools should execute through supplied context', async () => {
const permissions: unknown[] = [];
const files = new Map<string, string>([['hello.txt', 'line1\nline2\nline3']]);
const tools = createFilesystemTools({
requestPermission: async (request) => {
permissions.push(request);
},
fs: {
readFile: async (filePath, options) => {
const content = files.get(filePath) ?? '';
if (options?.startLine || options?.endLine) {
const lines = content.split('\n');
return lines.slice((options.startLine ?? 1) - 1, options.endLine ?? lines.length).join('\n');
}
return content;
},
writeFile: async (filePath, content) => {
files.set(filePath, content);
return 'written';
},
listDirectory: async () => [...files.keys()],
},
}, { includeDelete: false });
const readResult = await (tools.read_file as any).execute({ path: 'hello.txt', startLine: 2, endLine: 2 });
const writeResult = await (tools.write_file as any).execute({ path: 'created.txt', content: 'created' });
const listResult = await (tools.list_directory as any).execute({ path: '.' });
expect(readResult).toEqual('line2');
expect(writeResult).toEqual('written');
expect(listResult).toInclude('created.txt');
expect(Object.keys(tools)).not.toContain('delete_file');
expect(JSON.stringify(permissions[0])).toInclude('created.txt');
});
tap.test('createBrowserTools should execute through supplied browser context', async () => {
const permissions: unknown[] = [];
const calls: unknown[] = [];
const tools = createBrowserTools({
requestPermission: async (request) => {
permissions.push(request);
},
browser: {
execute: async (input, options) => {
calls.push({ input, options });
return `browser:${input.action}:${input.url ?? ''}`;
},
},
});
const result = await (tools.browser as any).execute({ action: 'navigate', url: 'https://example.com', timeoutMs: 500 });
expect(result).toEqual('browser:navigate:https://example.com');
expect(JSON.stringify(calls[0])).toInclude('navigate');
expect(JSON.stringify(permissions[0])).toInclude('https://example.com');
});
tap.test('json_validate tool should validate valid JSON', async () => {
const tools = jsonTool();
const result = await (tools.json_validate as any).execute({
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '3.2.0',
version: '3.7.0',
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
}
+29
View File
@@ -12,6 +12,35 @@ export type {
TAgentCacheRetention,
TAgentCacheSetting,
} from './smartagent.interfaces.js';
export {
createBrowserTools,
createFilesystemTools,
createLocalToolExecutionContext,
createShellTools,
filesystemTool,
formatShellResult,
formatToolOutput,
shellTool,
} from '../ts_tools/index.js';
export type {
IBrowserToolInput,
ICreateBrowserToolsOptions,
ICreateFilesystemToolsOptions,
ICreateShellToolsOptions,
IFilesystemToolOptions,
ILocalToolExecutionContextOptions,
IShellToolOptions,
IToolBrowserContext,
IToolExecutionContext,
IToolFilesystemContext,
IToolFilesystemListOptions,
IToolFilesystemReadOptions,
IToolPermissionRequest,
IToolRunOptions,
IToolShellContext,
IToolShellResult,
TBrowserToolAction,
} from '../ts_tools/index.js';
// Re-export tool() and z so consumers can define tools without extra imports
export { tool, jsonSchema } from '@push.rocks/smartai';
+32 -2
View File
@@ -156,6 +156,7 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
let validationRetries = 0;
const toolCalls: IAgentToolCallRecord[] = [];
const toolCallIndexes = new Map<string, number>();
const reasoningTextById = new Map<string, string>();
const tools = options.tools ?? {};
const cache = options.cache ?? 'auto';
@@ -227,8 +228,33 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
},
onChunk: ({ chunk }) => {
if (chunk.type === 'text-delta' && options.onToken) {
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
const chunkType = String((chunk as any).type || '');
if (chunkType === 'text-delta' && options.onToken) {
options.onToken((chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '');
return;
}
if (chunkType === 'reasoning-start') {
const id = (chunk as any).id || 'reasoning';
reasoningTextById.set(id, '');
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
return;
}
if (chunkType === 'reasoning-delta') {
const id = (chunk as any).id || 'reasoning';
const delta = (chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '';
if (!reasoningTextById.has(id)) {
reasoningTextById.set(id, '');
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
}
reasoningTextById.set(id, (reasoningTextById.get(id) ?? '') + delta);
options.onReasoningDelta?.(id, delta, (chunk as any).providerMetadata);
return;
}
if (chunkType === 'reasoning-end') {
const id = (chunk as any).id || 'reasoning';
const text = reasoningTextById.get(id) ?? '';
reasoningTextById.delete(id);
options.onReasoningEnd?.(id, text, (chunk as any).providerMetadata);
}
},
@@ -286,6 +312,10 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
const finishReason = await result.finishReason;
const responseData = await result.response;
const responseMessages = responseData.messages as plugins.ModelMessage[];
for (const [id, reasoningText] of reasoningTextById) {
options.onReasoningEnd?.(id, reasoningText);
reasoningTextById.delete(id);
}
attempt = 0; // reset on success
+6
View File
@@ -45,6 +45,12 @@ export interface IAgentRunOptions {
messages?: ModelMessage[];
/** Called for each streamed text delta */
onToken?: (delta: string) => void;
/** Called when the model starts a streamed reasoning summary */
onReasoningStart?: (id: string, providerMetadata?: unknown) => void;
/** Called for each streamed reasoning summary delta */
onReasoningDelta?: (id: string, delta: string, providerMetadata?: unknown) => void;
/** Called when a streamed reasoning summary completes */
onReasoningEnd?: (id: string, text: string, providerMetadata?: unknown) => void;
/** Called when a tool call starts */
onToolCall?: (toolName: string, input: unknown) => void;
/** Called when a tool call completes */
+344
View File
@@ -0,0 +1,344 @@
import { Client, type ClientOptions } from '@modelcontextprotocol/sdk/client/index.js';
import {
StdioClientTransport,
type StdioServerParameters,
} from '@modelcontextprotocol/sdk/client/stdio.js';
import {
StreamableHTTPClientTransport,
type StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { jsonSchema, tool } from '@push.rocks/smartai';
import type { ToolSet } from '@push.rocks/smartai';
export type TMcpListToolsResult = Awaited<ReturnType<Client['listTools']>>;
export type TMcpToolMetadata = TMcpListToolsResult['tools'][number];
export type TMcpCallToolResult = Awaited<ReturnType<Client['callTool']>>;
export interface IMcpClientLike {
listTools(params?: { cursor?: string }, options?: RequestOptions): Promise<TMcpListToolsResult>;
callTool(
params: { name: string; arguments?: Record<string, unknown> },
resultSchema?: unknown,
options?: RequestOptions,
): Promise<TMcpCallToolResult>;
close?(): Promise<void>;
}
export interface IMcpServerClientOptions {
clientName?: string;
clientVersion?: string;
clientOptions?: ClientOptions;
requestOptions?: RequestOptions;
}
export interface IMcpStdioServerConfig extends StdioServerParameters, IMcpServerClientOptions {
type: 'stdio';
}
export interface IMcpStreamableHttpServerConfig extends IMcpServerClientOptions {
type: 'streamableHttp' | 'http';
url: string | URL;
transportOptions?: StreamableHTTPClientTransportOptions;
}
export interface IMcpTransportServerConfig extends IMcpServerClientOptions {
type: 'transport';
transport: Transport;
}
export interface IMcpClientServerConfig {
type: 'client';
client: IMcpClientLike;
}
export type TMcpServerConfig =
| IMcpStdioServerConfig
| IMcpStreamableHttpServerConfig
| IMcpTransportServerConfig
| IMcpClientServerConfig;
export interface IMcpToolFormatContext {
serverName: string;
toolName: string;
exposedToolName: string;
}
export interface ICreateMcpToolsOptions {
servers: Record<string, TMcpServerConfig>;
clientName?: string;
clientVersion?: string;
clientOptions?: ClientOptions;
requestOptions?: RequestOptions;
listToolsRequestOptions?: RequestOptions;
callToolRequestOptions?: RequestOptions;
prefixToolNames?: boolean;
formatResult?: (result: TMcpCallToolResult, context: IMcpToolFormatContext) => unknown;
}
export interface IMcpConnectedServer {
name: string;
client: IMcpClientLike;
tools: TMcpToolMetadata[];
}
export interface IMcpToolNameMapping {
serverName: string;
toolName: string;
}
export interface ICreateMcpToolsResult {
tools: ToolSet;
servers: IMcpConnectedServer[];
toolNameMap: Record<string, IMcpToolNameMapping>;
close: () => Promise<void>;
}
export const createMcpTools = async (
options: ICreateMcpToolsOptions,
): Promise<ICreateMcpToolsResult> => {
const serverEntries = Object.entries(options.servers);
if (serverEntries.length === 0) {
throw new Error('createMcpTools requires at least one MCP server.');
}
const connectedServers: IMcpConnectedServer[] = [];
try {
for (const [serverName, config] of serverEntries) {
const client = await connectMcpServer(serverName, config, options);
const tools = await listAllMcpTools(
client,
options.listToolsRequestOptions ?? getServerRequestOptions(config) ?? options.requestOptions,
);
connectedServers.push({ name: serverName, client, tools });
}
const exposedTools: ToolSet = {};
const toolNameMap: Record<string, IMcpToolNameMapping> = {};
const usedToolNames = new Set<string>();
const formatResult = options.formatResult ?? formatMcpToolResult;
const shouldPrefixToolNames = options.prefixToolNames ?? connectedServers.length > 1;
for (const server of connectedServers) {
for (const mcpTool of server.tools) {
const baseToolName = createExposedToolName(server.name, mcpTool.name, shouldPrefixToolNames);
const exposedToolName = createUniqueToolName(baseToolName, usedToolNames);
usedToolNames.add(exposedToolName);
toolNameMap[exposedToolName] = {
serverName: server.name,
toolName: mcpTool.name,
};
exposedTools[exposedToolName] = tool({
description: mcpTool.description ?? `MCP tool ${mcpTool.name} from ${server.name}`,
inputSchema: jsonSchema(mcpTool.inputSchema as never),
execute: async (input: unknown) => {
const result = await server.client.callTool(
{
name: mcpTool.name,
arguments: normalizeMcpArguments(input),
},
undefined,
options.callToolRequestOptions ?? options.requestOptions,
);
return formatResult(result, {
serverName: server.name,
toolName: mcpTool.name,
exposedToolName,
});
},
});
}
}
return {
tools: exposedTools,
servers: connectedServers,
toolNameMap,
close: async () => closeMcpClients(connectedServers),
};
} catch (error) {
await closeMcpClients(connectedServers);
throw error;
}
};
export const sanitizeMcpToolName = (name: string, fallback = 'mcp_tool'): string => {
const sanitized = name
.trim()
.replace(/[^a-zA-Z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '');
const safeName = sanitized || fallback;
return /^[a-zA-Z_]/.test(safeName) ? safeName : `_${safeName}`;
};
export const formatMcpToolResult = (result: TMcpCallToolResult): unknown => {
const resultRecord = result as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(resultRecord, 'toolResult')) {
return resultRecord.toolResult;
}
const content = Array.isArray(resultRecord.content)
? resultRecord.content.map(formatMcpContentPart).filter(Boolean).join('\n\n')
: '';
const formattedContent = resultRecord.isError && content
? `MCP tool returned an error:\n${content}`
: content;
const structuredContent = resultRecord.structuredContent;
if (structuredContent !== undefined && formattedContent) {
return `${formattedContent}\n\nStructured content:\n${stringifyJson(structuredContent)}`;
}
if (structuredContent !== undefined) {
return structuredContent;
}
if (formattedContent) {
return formattedContent;
}
return stringifyJson(result);
};
const connectMcpServer = async (
serverName: string,
config: TMcpServerConfig,
options: ICreateMcpToolsOptions,
): Promise<IMcpClientLike> => {
if (config.type === 'client') {
return config.client;
}
const client = new Client(
{
name: config.clientName ?? options.clientName ?? 'smartagent-mcp',
version: config.clientVersion ?? options.clientVersion ?? '1.0.0',
},
config.clientOptions ?? options.clientOptions ?? { capabilities: {} },
);
const transport = createMcpTransport(config);
await client.connect(transport, config.requestOptions ?? options.requestOptions);
return client;
};
const createMcpTransport = (
config: Exclude<TMcpServerConfig, IMcpClientServerConfig>,
): Transport => {
if (config.type === 'stdio') {
const { type, clientName, clientVersion, clientOptions, requestOptions, ...serverParameters } = config;
return new StdioClientTransport(serverParameters);
}
if (config.type === 'streamableHttp' || config.type === 'http') {
return new StreamableHTTPClientTransport(new URL(config.url), config.transportOptions);
}
if (config.type === 'transport') {
return config.transport;
}
throw new Error('Unsupported MCP server transport.');
};
const getServerRequestOptions = (config: TMcpServerConfig): RequestOptions | undefined => {
return config.type === 'client' ? undefined : config.requestOptions;
};
const listAllMcpTools = async (
client: IMcpClientLike,
requestOptions?: RequestOptions,
): Promise<TMcpToolMetadata[]> => {
const tools: TMcpToolMetadata[] = [];
let cursor: string | undefined;
do {
const result = await client.listTools(cursor ? { cursor } : undefined, requestOptions);
tools.push(...result.tools);
cursor = result.nextCursor;
} while (cursor);
return tools;
};
const createExposedToolName = (
serverName: string,
toolName: string,
prefixToolName: boolean,
): string => {
const safeToolName = sanitizeMcpToolName(toolName);
if (!prefixToolName) {
return safeToolName;
}
return `${sanitizeMcpToolName(serverName, 'mcp')}__${safeToolName}`;
};
const createUniqueToolName = (baseToolName: string, usedToolNames: Set<string>): string => {
if (!usedToolNames.has(baseToolName)) {
return baseToolName;
}
let suffix = 2;
while (usedToolNames.has(`${baseToolName}_${suffix}`)) {
suffix++;
}
return `${baseToolName}_${suffix}`;
};
const normalizeMcpArguments = (input: unknown): Record<string, unknown> => {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {};
}
return input as Record<string, unknown>;
};
const formatMcpContentPart = (part: unknown): string => {
if (!part || typeof part !== 'object') {
return stringifyJson(part);
}
const partRecord = part as Record<string, unknown>;
const type = partRecord.type;
if (type === 'text') {
return typeof partRecord.text === 'string' ? partRecord.text : '';
}
if (type === 'image' || type === 'audio') {
const data = typeof partRecord.data === 'string' ? partRecord.data : '';
const mimeType = typeof partRecord.mimeType === 'string' ? partRecord.mimeType : 'application/octet-stream';
return `[${type}: ${mimeType}; base64 length ${data.length}]`;
}
if (type === 'resource') {
return formatMcpResourcePart(partRecord.resource);
}
if (type === 'resource_link') {
const name = typeof partRecord.name === 'string' ? partRecord.name : 'resource';
const uri = typeof partRecord.uri === 'string' ? partRecord.uri : '';
return `Resource link ${name}${uri ? `: ${uri}` : ''}`;
}
return stringifyJson(part);
};
const formatMcpResourcePart = (resource: unknown): string => {
if (!resource || typeof resource !== 'object') {
return stringifyJson(resource);
}
const resourceRecord = resource as Record<string, unknown>;
const uri = typeof resourceRecord.uri === 'string' ? resourceRecord.uri : 'resource';
if (typeof resourceRecord.text === 'string') {
return `Resource ${uri}:\n${resourceRecord.text}`;
}
if (typeof resourceRecord.blob === 'string') {
const mimeType = typeof resourceRecord.mimeType === 'string' ? resourceRecord.mimeType : 'application/octet-stream';
return `[resource ${uri}: ${mimeType}; base64 length ${resourceRecord.blob.length}]`;
}
return stringifyJson(resource);
};
const stringifyJson = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, undefined, 2);
} catch {
return String(value);
}
};
const closeMcpClients = async (servers: IMcpConnectedServer[]): Promise<void> => {
await Promise.all(servers.map((server) => server.client.close?.()));
};
+21 -4
View File
@@ -1,7 +1,24 @@
export { filesystemTool } from './tool.filesystem.js';
export type { IFilesystemToolOptions } from './tool.filesystem.js';
export { shellTool } from './tool.shell.js';
export type { IShellToolOptions } from './tool.shell.js';
export { createBrowserTools } from './tool.browser.js';
export type { ICreateBrowserToolsOptions } from './tool.browser.js';
export { createFilesystemTools, filesystemTool } from './tool.filesystem.js';
export type { ICreateFilesystemToolsOptions, IFilesystemToolOptions } from './tool.filesystem.js';
export { createShellTools, shellTool } from './tool.shell.js';
export type { ICreateShellToolsOptions, IShellToolOptions } from './tool.shell.js';
export { createLocalToolExecutionContext, formatShellResult, formatToolOutput } from './tool.context.js';
export type {
IBrowserToolInput,
ILocalToolExecutionContextOptions,
IToolBrowserContext,
IToolExecutionContext,
IToolFilesystemContext,
IToolFilesystemListOptions,
IToolFilesystemReadOptions,
IToolPermissionRequest,
IToolRunOptions,
IToolShellContext,
IToolShellResult,
TBrowserToolAction,
} from './tool.context.js';
export { httpTool } from './tool.http.js';
export { jsonTool } from './tool.json.js';
export { truncateOutput } from './plugins.js';
+4 -3
View File
@@ -1,8 +1,9 @@
// node native
import * as path from 'path';
import * as fs from 'fs';
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
export { path, fs };
export { childProcess, fs, path };
// zod
import { z } from 'zod';
+94
View File
@@ -0,0 +1,94 @@
import * as plugins from './plugins.js';
import {
formatToolOutput,
type IBrowserToolInput,
type IToolExecutionContext,
type TBrowserToolAction,
} from './tool.context.js';
export interface ICreateBrowserToolsOptions {
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
const browserActions = new Set<TBrowserToolAction>(['navigate', 'snapshot', 'screenshot', 'click', 'fill', 'press', 'evaluate', 'close']);
export function createBrowserTools(context: IToolExecutionContext, options: ICreateBrowserToolsOptions = {}): plugins.ToolSet {
return {
browser: plugins.tool({
description: [
'Control a browser supplied by the host execution context for web UI inspection and interaction.',
'Actions: navigate, snapshot, screenshot, click, fill, press, evaluate, close.',
'Use snapshot after navigation or interaction to inspect page text and interactive selectors before choosing the next action.',
'Actions that navigate or modify page state require host permission when configured.',
].join(' '),
inputSchema: plugins.z.object({
action: plugins.z.string().default('snapshot').describe('Action: navigate, snapshot, screenshot, click, fill, press, evaluate, or close'),
url: plugins.z.string().optional().describe('URL for navigate'),
selector: plugins.z.string().optional().describe('CSS or Playwright selector for click/fill'),
text: plugins.z.string().optional().describe('Text for fill, key name for press, or screenshot mode/full-page hint'),
script: plugins.z.string().optional().describe('JavaScript expression or function body for evaluate'),
timeoutMs: plugins.z.number().optional().describe('Optional action timeout in milliseconds'),
}),
execute: async (input: IBrowserToolInput) => {
if (!context.browser) {
throw new Error('Browser tool is not available in this execution context.');
}
const action = normalizeBrowserAction(input.action);
await requestBrowserPermission(context, { ...input, action });
const result = await context.browser.execute({ ...input, action }, {
timeoutMs: input.timeoutMs,
abortSignal: context.abortSignal,
});
return plugins.truncateOutput(formatToolOutput(result), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
},
}),
};
}
const normalizeBrowserAction = (input: unknown): TBrowserToolAction => {
const action = typeof input === 'string' && input.trim()
? input.trim().toLowerCase()
: 'snapshot';
if (browserActions.has(action as TBrowserToolAction)) {
return action as TBrowserToolAction;
}
throw new Error(`Unsupported browser action: ${String(input)}. Use one of: ${[...browserActions].join(', ')}.`);
};
const requestBrowserPermission = async (context: IToolExecutionContext, input: IBrowserToolInput & { action: TBrowserToolAction }): Promise<void> => {
if (!context.requestPermission) return;
if (input.action === 'snapshot' || input.action === 'screenshot') return;
const titleByAction: Record<TBrowserToolAction, string> = {
navigate: 'Navigate browser',
snapshot: 'Inspect browser',
screenshot: 'Capture browser screenshot',
click: 'Click browser element',
fill: 'Fill browser element',
press: 'Press browser key',
evaluate: 'Evaluate browser JavaScript',
close: 'Close browser session',
};
await context.requestPermission({
type: 'browser',
title: titleByAction[input.action],
metadata: {
action: input.action,
url: input.url,
selector: input.selector,
key: input.action === 'press' ? input.text : undefined,
textLength: input.action === 'fill' ? input.text?.length ?? 0 : undefined,
scriptPreview: input.action === 'evaluate' && input.script ? compactMetadataText(input.script) : undefined,
},
});
};
const compactMetadataText = (text: string): string => {
const compacted = text.replace(/\s+/g, ' ').trim();
return compacted.length > 160 ? `${compacted.slice(0, 157)}...` : compacted;
};
+207
View File
@@ -0,0 +1,207 @@
import * as plugins from './plugins.js';
export interface IToolPermissionRequest {
type: string;
title: string;
metadata?: Record<string, unknown>;
}
export interface IToolRunOptions {
cwd?: string;
timeoutMs?: number;
abortSignal?: AbortSignal;
}
export interface IToolShellResult {
exitCode: number;
stdout: string;
stderr?: string;
signal?: string;
}
export interface IToolShellContext {
run(command: string, options?: IToolRunOptions): Promise<IToolShellResult | string>;
}
export interface IToolFilesystemReadOptions {
startLine?: number;
endLine?: number;
}
export interface IToolFilesystemListOptions {
recursive?: boolean;
}
export interface IToolFilesystemContext {
readFile(filePath: string, options?: IToolFilesystemReadOptions): Promise<string>;
writeFile(filePath: string, content: string): Promise<string | void>;
listDirectory(directoryPath: string, options?: IToolFilesystemListOptions): Promise<string[] | string>;
deletePath?(targetPath: string): Promise<string | void>;
}
export type TBrowserToolAction = 'navigate' | 'snapshot' | 'screenshot' | 'click' | 'fill' | 'press' | 'evaluate' | 'close';
export interface IBrowserToolInput {
action?: TBrowserToolAction | string;
url?: string;
selector?: string;
text?: string;
script?: string;
timeoutMs?: number;
}
export interface IToolBrowserContext {
execute(input: IBrowserToolInput, options?: { timeoutMs?: number; abortSignal?: AbortSignal }): Promise<unknown>;
}
export interface IToolExecutionContext {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
shell?: IToolShellContext;
fs?: IToolFilesystemContext;
browser?: IToolBrowserContext;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export interface ILocalToolExecutionContextOptions {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
const cwd = options.cwd ?? process.cwd();
const rootDir = options.rootDir;
return {
cwd,
rootDir,
abortSignal: options.abortSignal,
requestPermission: options.requestPermission,
shell: {
run: (command, runOptions) => runLocalShellCommand(command, {
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
timeoutMs: runOptions?.timeoutMs,
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
}),
},
fs: {
readFile: async (filePath, readOptions) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
const content = await plugins.fs.promises.readFile(resolved, 'utf8');
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
const lines = content.split('\n');
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
const end = Math.max(readOptions.endLine ?? lines.length, start);
return lines.slice(start, end).join('\n');
}
return content;
},
writeFile: async (filePath, content) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
await plugins.fs.promises.mkdir(plugins.path.dirname(resolved), { recursive: true });
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
},
listDirectory: async (directoryPath, listOptions) => {
const resolved = resolveLocalPath(directoryPath, rootDir, cwd);
return listLocalDirectory(resolved, !!listOptions?.recursive);
},
deletePath: async (targetPath) => {
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
return `Deleted ${targetPath}`;
},
},
};
};
export const formatShellResult = (result: IToolShellResult | string): string => {
if (typeof result === 'string') return result;
if (result.exitCode === 0) return result.stdout;
return [
`Exit code: ${result.exitCode}`,
result.signal ? `Signal: ${result.signal}` : '',
`stdout:\n${result.stdout}`,
`stderr:\n${result.stderr ?? ''}`,
].filter(Boolean).join('\n');
};
export const formatToolOutput = (output: unknown): string => {
if (typeof output === 'string') return output;
try {
return JSON.stringify(output, undefined, 2);
} catch {
return String(output);
}
};
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
const base = rootDir ?? baseDir ?? process.cwd();
const resolved = plugins.path.isAbsolute(targetPath)
? plugins.path.resolve(targetPath)
: plugins.path.resolve(base, targetPath);
if (rootDir) {
const resolvedRoot = plugins.path.resolve(rootDir);
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
}
}
return resolved;
};
const listLocalDirectory = async (directoryPath: string, recursive: boolean): Promise<string[]> => {
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
result.push(relativePath);
if (recursive && entry.isDirectory()) {
const childEntries = await listLocalDirectory(plugins.path.join(directoryPath, entry.name), true);
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
}
}
return result;
};
const runLocalShellCommand = async (command: string, options: IToolRunOptions): Promise<IToolShellResult> => {
return new Promise<IToolShellResult>((resolve) => {
const child = plugins.childProcess.spawn('bash', ['-lc', command], {
cwd: options.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs)
: undefined;
const abort = () => child.kill('SIGTERM');
options.abortSignal?.addEventListener('abort', abort, { once: true });
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({ exitCode: 1, stdout, stderr: `${stderr}${error.message}` });
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({
exitCode: code ?? (timedOut ? 124 : 1),
stdout,
stderr: timedOut ? `${stderr}\nCommand timed out after ${options.timeoutMs}ms.`.trim() : stderr,
signal: signal ?? undefined,
});
});
});
};
+80 -78
View File
@@ -1,30 +1,43 @@
import * as plugins from './plugins.js';
import {
createLocalToolExecutionContext,
formatToolOutput,
type IToolExecutionContext,
} from './tool.context.js';
export interface IFilesystemToolOptions {
/** Restrict file access to this directory. Default: process.cwd() */
rootDir?: string;
/** Execution context. Defaults to a local Node.js context. */
context?: IToolExecutionContext;
/** Include delete_file. Default: true for compatibility. */
includeDelete?: boolean;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
function validatePath(filePath: string, rootDir?: string): string {
const resolved = plugins.path.resolve(filePath);
if (rootDir) {
const resolvedRoot = plugins.path.resolve(rootDir);
if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) {
throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`);
}
}
return resolved;
export interface ICreateFilesystemToolsOptions {
/** Include delete_file. Default: true. */
includeDelete?: boolean;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
const rootDir = options?.rootDir;
return {
export function createFilesystemTools(context: IToolExecutionContext, options: ICreateFilesystemToolsOptions = {}): plugins.ToolSet {
const truncate = (output: unknown) => plugins.truncateOutput(formatToolOutput(output), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
const tools: plugins.ToolSet = {
read_file: plugins.tool({
description:
'Read file contents. Returns the full text or a specified line range.',
'Read a UTF-8 file in the active workspace. Paths may be absolute or relative to the workspace root.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Absolute path to the file'),
path: plugins.z.string().describe('File path'),
startLine: plugins.z
.number()
.optional()
@@ -43,89 +56,78 @@ export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSe
startLine?: number;
endLine?: number;
}) => {
const resolved = validatePath(filePath, rootDir);
const content = plugins.fs.readFileSync(resolved, 'utf-8');
if (startLine !== undefined || endLine !== undefined) {
const lines = content.split('\n');
const start = (startLine ?? 1) - 1;
const end = endLine ?? lines.length;
const sliced = lines.slice(start, end).join('\n');
return plugins.truncateOutput(sliced).content;
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
return plugins.truncateOutput(content).content;
return truncate(await context.fs.readFile(filePath, { startLine, endLine }));
},
}),
write_file: plugins.tool({
description:
'Write content to a file (creates parent dirs if needed, overwrites existing).',
'Write UTF-8 content to a file in the active workspace. Creates parent directories and overwrites existing content. Requires host permission when configured.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Absolute path to the file'),
content: plugins.z.string().describe('Content to write'),
path: plugins.z.string().describe('File path'),
content: plugins.z.string().describe('Complete file content to write'),
}),
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
const resolved = validatePath(filePath, rootDir);
const dir = plugins.path.dirname(resolved);
plugins.fs.mkdirSync(dir, { recursive: true });
plugins.fs.writeFileSync(resolved, content, 'utf-8');
return `Written ${content.length} characters to ${filePath}`;
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
await context.requestPermission?.({
type: 'write',
title: 'Write file',
metadata: { path: filePath, bytes: Buffer.byteLength(content, 'utf8') },
});
const result = await context.fs.writeFile(filePath, content);
return truncate(result ?? `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`);
},
}),
list_directory: plugins.tool({
description: 'List files and directories at the given path.',
description: 'List files and directories in the active workspace. Paths may be absolute or relative to the workspace root.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Directory path to list'),
path: plugins.z.string().default('.').describe('Directory path to list'),
recursive: plugins.z
.boolean()
.optional()
.describe('List recursively (default: false)'),
.describe('List recursively. Default: false'),
}),
execute: async ({
path: dirPath,
recursive,
}: {
path: string;
recursive?: boolean;
}) => {
const resolved = validatePath(dirPath, rootDir);
function listDir(dir: string, prefix: string = ''): string[] {
const entries = plugins.fs.readdirSync(dir, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const indicator = entry.isDirectory() ? '/' : '';
result.push(`${rel}${indicator}`);
if (recursive && entry.isDirectory()) {
result.push(...listDir(plugins.path.join(dir, entry.name), rel));
}
}
return result;
execute: async ({ path: directoryPath, recursive }: { path: string; recursive?: boolean }) => {
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
const entries = listDir(resolved);
return plugins.truncateOutput(entries.join('\n')).content;
},
}),
delete_file: plugins.tool({
description: 'Delete a file or empty directory.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Path to delete'),
}),
execute: async ({ path: filePath }: { path: string }) => {
const resolved = validatePath(filePath, rootDir);
const stat = plugins.fs.statSync(resolved);
if (stat.isDirectory()) {
plugins.fs.rmdirSync(resolved);
} else {
plugins.fs.unlinkSync(resolved);
}
return `Deleted ${filePath}`;
const result = await context.fs.listDirectory(directoryPath, { recursive });
return truncate(Array.isArray(result) ? result.join('\n') : result);
},
}),
};
if (options.includeDelete !== false) {
tools.delete_file = plugins.tool({
description: 'Delete a file or empty directory in the active workspace. Requires host permission when configured.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Path to delete'),
}),
execute: async ({ path: targetPath }: { path: string }) => {
if (!context.fs?.deletePath) {
throw new Error('Deleting files is not available in this execution context.');
}
await context.requestPermission?.({
type: 'delete',
title: 'Delete file',
metadata: { path: targetPath },
});
const result = await context.fs.deletePath(targetPath);
return truncate(result ?? `Deleted ${targetPath}`);
},
});
}
return tools;
}
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
const context = options?.context ?? createLocalToolExecutionContext({ rootDir: options?.rootDir });
return createFilesystemTools(context, options);
}
+51 -17
View File
@@ -1,19 +1,37 @@
import * as plugins from './plugins.js';
import {
createLocalToolExecutionContext,
formatShellResult,
type IToolExecutionContext,
} from './tool.context.js';
export interface IShellToolOptions {
/** Allowed commands whitelist. If empty, all commands are allowed. */
allowedCommands?: string[];
/** Working directory for shell execution */
cwd?: string;
/** Execution context. Defaults to a local Node.js context. */
context?: IToolExecutionContext;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
export interface ICreateShellToolsOptions {
/** Allowed commands whitelist. If empty, all commands are allowed. */
allowedCommands?: string[];
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export function createShellTools(context: IToolExecutionContext, options: ICreateShellToolsOptions = {}): plugins.ToolSet {
return {
run_command: plugins.tool({
description:
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
'Execute a shell command in the active workspace. Provide the full command string. stdout and stderr are returned.',
inputSchema: plugins.z.object({
command: plugins.z.string().describe('The shell command to execute'),
cwd: plugins.z
@@ -24,39 +42,55 @@ export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
.number()
.optional()
.describe('Timeout in milliseconds'),
timeoutMs: plugins.z
.number()
.optional()
.describe('Timeout in milliseconds'),
}),
execute: async ({
command,
cwd,
timeout,
timeoutMs,
}: {
command: string;
cwd?: string;
timeout?: number;
timeoutMs?: number;
}) => {
// Validate against allowed commands whitelist
if (options?.allowedCommands?.length) {
if (!context.shell) {
throw new Error('Shell tool is not available in this execution context.');
}
if (options.allowedCommands?.length) {
const baseCommand = command.split(/\s+/)[0];
if (!options.allowedCommands.includes(baseCommand)) {
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
}
}
// Build full command string with cd prefix if cwd specified
const effectiveCwd = cwd ?? options?.cwd;
const fullCommand = effectiveCwd
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
: command;
await context.requestPermission?.({
type: 'shell',
title: 'Run shell command',
metadata: { command, cwd: cwd ?? context.cwd },
});
const execResult = await smartshell.exec(fullCommand);
const execResult = await context.shell.run(command, {
cwd: cwd ?? context.cwd,
timeoutMs: timeoutMs ?? timeout,
abortSignal: context.abortSignal,
});
const output =
execResult.exitCode === 0
? execResult.stdout
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
return plugins.truncateOutput(output).content;
return plugins.truncateOutput(formatShellResult(execResult), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
},
}),
};
}
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
const context = options?.context ?? createLocalToolExecutionContext({ cwd: options?.cwd });
return createShellTools(context, options);
}