Compare commits

...

4 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
7 changed files with 1111 additions and 2 deletions
+19
View File
@@ -7,6 +7,25 @@
## 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
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartagent",
"version": "3.5.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,6 +41,7 @@
"@types/node": "^25.6.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@push.rocks/smartai": "^4.0.0",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartrequest": "^5.0.1",
@@ -57,6 +62,7 @@
"ts/**/*",
"ts_tools/**/*",
"ts_compaction/**/*",
"ts_mcp/**/*",
"dist/**/*",
"dist_*/**/*",
"assets/**/*",
+561
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -76,6 +76,7 @@ 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
- 💰 **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
@@ -168,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.
@@ -242,6 +244,40 @@ 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.
+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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '3.5.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.'
}
+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?.()));
};