@mcpc-tech/acp-ai-provider
v0.3.2
Published
[](https://www.npmjs.com/package/@mcpc-tech/acp-ai-provider) [](https://jsr.io/@mcpc/acp-ai-provider)
Downloads
7,688
Readme
@mcpc/acp-ai-provider
Version Compatibility
v0.2.xrequires AI SDK v6 (mainbranch)- For AI SDK v5, use the
release-v5branch or install@mcpc-tech/acp-ai-provider@ai-v5
Use ACP (Agent Client Protocol) agents with the AI SDK.

This package bridges ACP agents to the AI SDK. It spawns ACP agents (Claude
Code, Gemini, Codex CLI, and more) as child processes and exposes them through
the AI SDK's LanguageModelV3/LanguageModelV2 protocol.
Try a full stack web ACP example here
Installation
# npm
npm i @mcpc-tech/acp-ai-provider
# deno
deno add jsr:@mcpc/acp-ai-providerFor AI SDK v5 users:
# npm (v5)
npm i @mcpc-tech/acp-ai-provider@ai-v5
# deno (v5)
deno add jsr:@mcpc/[email protected]Usage
Basic Example
import { createACPProvider } from "@mcpc/acp-ai-provider";
import { generateText } from "ai";
import process from "node:process";
// Create provider for an ACP agent
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
session: {
cwd: process.cwd(),
mcpServers: [],
},
});
// Use with AI SDK
const result = await generateText({
model: provider.languageModel(),
prompt: "Hello, what can you help me with?",
tools: provider.tools,
});
console.log(result.text);Streaming Example
import { createACPProvider } from "@mcpc/acp-ai-provider";
import { streamText } from "ai";
import process from "node:process";
const provider = createACPProvider({
command: "claude-agent-acp",
args: [],
session: {
cwd: process.cwd(),
mcpServers: [],
},
});
const { textStream } = streamText({
model: provider.languageModel(),
prompt: "Write a simple Hello World program",
tools: provider.tools,
});
for await (const chunk of textStream) {
process.stdout.write(chunk);
}Structured JSON Output
Use AI SDK's Output.object() to get structured JSON from ACP agents. The
provider automatically injects JSON schema instructions into the prompt and
strips markdown fences from the response if needed.
import { createACPProvider } from "@mcpc/acp-ai-provider";
import { generateText, Output, streamText } from "ai";
import process from "node:process";
import { z } from "zod";
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
session: { cwd: process.cwd(), mcpServers: [] },
});
const result = await generateText({
model: provider.languageModel(),
prompt: "Give me a recipe for chocolate chip cookies.",
output: Output.object({
schema: z.object({
name: z.string(),
ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
steps: z.array(z.string()),
}),
}),
});
console.log(result.output); // Typed object matching the schemaThis also works with streamText:
const stream = streamText({
model: provider.languageModel(),
prompt: "Tell me about Tokyo",
output: Output.object({
schema: z.object({
name: z.string(),
country: z.string(),
landmarks: z.array(z.string()),
}),
}),
});
const output = await stream.output; // Parsed objectHow it works: When
Output.object()(orOutput.array(),Output.json(),Output.choice()) is used, AI SDK setsresponseFormat.type = "json"with an optional JSON Schema. The ACP provider detects this and:
- Prepends a structured-output instruction (with the schema) to the prompt
- Strips markdown code fences from the response (in both generate and stream modes)
- Passes clean JSON text to AI SDK for validation and parsing
Authentication (Lazy by Default)
Authentication is lazy by default: the provider does not authenticate during
initialize. If an ACP request fails with an auth-required error, the provider
will:
- call
authenticate(authMethodId) - retry the request once
By default, if authMethodId is not set and initialize.authMethods is
available, the provider will use the first method and print a warning.
To explicitly control this flow, set authMethodId:
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
authMethodId: process.env.AUTH_METHOD_ID,
session: { cwd: process.cwd(), mcpServers: [] },
});You can also authenticate manually:
await provider.authenticate(process.env.AUTH_METHOD_ID);With Tools (MCP Servers)
Tools are defined through MCP (Model Context Protocol) servers, not AI SDK's
tools parameter:
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
session: {
cwd: process.cwd(),
mcpServers: [
{
type: "stdio",
name: "filesystem",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
],
},
});
const result = await generateText({
model: provider.languageModel(),
prompt: "List files in /tmp",
tools: provider.tools,
});Dynamic Host-Side Tools (Experimental)
You can also define AI SDK-style tools that execute on the host side using
acpTools():
import { acpTools, createACPProvider } from "@mcpc/acp-ai-provider";
import { streamText, tool } from "ai";
import { z } from "zod";
const provider = createACPProvider({
command: "claude-agent-acp",
session: { cwd: process.cwd(), mcpServers: [] },
});
const result = await streamText({
model: provider.languageModel(),
prompt: "Please greet Alice",
// acpTools() registers host-side tools for the agent to call
tools: acpTools({
greet: tool({
description: "Greet a person by name",
inputSchema: z.object({
name: z.string().describe("The name of the person to greet"),
}),
execute: async ({ name }) => `Hello, ${name}!`,
}),
}),
});Tool Result Format
By default, tools can return simple values like strings or objects:
execute: async ({ name }) => `Hello, ${name}!`,
execute: async () => ({ status: "ok" }),These are automatically wrapped into the MCP CallToolResult format with a
text content block.
However, for tools that return rich content like images or audio, you should
return the MCP
CallToolResult
format directly:
execute: async ({ url }) => {
const response = await fetch(url);
const buffer = Buffer.from(await response.arrayBuffer());
return {
content: [
{
type: "text" as const,
text: `Fetched image from ${url}`,
},
{
type: "image" as const,
data: buffer.toString("base64"),
mimeType: response.headers.get("content-type") || "image/jpeg",
},
],
};
},Why? Under the hood, acpTools() are implemented as MCP tools. Simple
returns get wrapped as { content: [{ type: "text", text: ... }] }. If you
return an object with image or audio blocks without the MCP wrapper, those
media blocks get JSON-stringified into a text block and lost. By returning the
MCP CallToolResult format explicitly, the media data is preserved and sent to
the agent as proper MCP content blocks.
See image-tool-result-example.ts for a complete working example.
How It Works (TCP Socket Callback)
Since ACP agents spawn their own MCP server subprocesses, we use a TCP socket for the runtime to call back to the host for tool execution:
┌─────────────────────────────────────────────────────────┐
│ Host Process │
│ - Starts TCP server (random port) │
│ - Passes TCP port via env vars to ACP │
│ ▲ │ │
│ │ │ TCP (getTools → definitions)
│ │ │ TCP (callHandler → execute)
│ │ │ │
└─────────────────────────┼────┼──────────────────────────┘
│ │
┌─────────────────────────┼────┼──────────────────────────┐
│ ACP Agent spawns tool-proxy-runtime │
│ - Reads port from ACP_TOOL_PROXY_PORT env │
│ - Connects and requests tools via `getTools` │
│ - On MCP tools/call → TCP callHandler → result │
└─────────────────────────────────────────────────────────┘Session Management
Key Difference: ACP providers maintain stateful sessions across multiple requests, while
streamTextitself is stateless. EachstreamTextcall is independent, but the underlying ACP agent process can persist conversation context when properly managed.
Simple Session Persistence (Single Provider)
For simple use cases, use persistSession to keep the same provider alive:
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
session: { cwd: process.cwd(), mcpServers: [] },
persistSession: true, // Keep session alive
});
const model = provider.languageModel();
await generateText({ model, prompt: "Hi, my name is Alice" });
await generateText({ model, prompt: "What's my name?" }); // Agent remembers
provider.cleanup(); // Clean up when doneResume a previous session:
const provider = createACPProvider({
command: "gemini",
args: ["--experimental-acp"],
session: { cwd: process.cwd(), mcpServers: [] },
existingSessionId: "previous-session-id",
persistSession: true,
});Multi-Session Management (Server/API Pattern)
For server applications handling multiple concurrent users, manage sessions in a Map:
interface SessionEntry {
provider: ReturnType<typeof createACPProvider>;
createdAt: number;
}
const sessionProviders = new Map<string, SessionEntry>();
// Initialize a new session
async function initSession(agentCommand: string): Promise<string> {
const provider = createACPProvider({
command: agentCommand,
args: [],
session: { cwd: process.cwd(), mcpServers: [] },
persistSession: true,
});
const session = await provider.initSession();
const sessionId = session.sessionId;
sessionProviders.set(sessionId, { provider, createdAt: Date.now() });
return sessionId;
}
// Use existing session
async function chat(sessionId: string, prompt: string) {
const entry = sessionProviders.get(sessionId);
if (!entry) throw new Error("Session not found");
const { textStream } = streamText({
model: entry.provider.languageModel(),
prompt,
tools: entry.provider.tools,
});
// Stream response...
}
// Cleanup session
function cleanupSession(sessionId: string) {
const entry = sessionProviders.get(sessionId);
if (entry) {
entry.provider.cleanup();
sessionProviders.delete(sessionId);
}
}See session-management-example.ts for a complete working example.
Selecting Models and Modes
Some ACP agents support multiple models or modes. Use initSession() to
discover and select them (or simply provide an arbitrary value to get an error
message listing available options):
const provider = createACPProvider({
command: "claude-agent-acp",
args: [],
session: { cwd: process.cwd(), mcpServers: [] },
persistSession: true,
});
// Initialize and get available options
const session = await provider.initSession();
// Check available modes (e.g., "default", "acceptEdits", "plan")
console.log(session.modes?.availableModes);
// Check available models (e.g. "default", "opus", "haiku")
console.log(session.models?.availableModels);
// Now use the model
const result = await generateText({
// You can optionally specify the model ID here
model: provider.languageModel("opus", "plan"),
prompt: "...",
});FAQ
How to stream tool calls
Tools are passed to the AI SDK as provider-defined tools because they are called and executed by the ACP agent (for example, Codex).
So, to stream tool calls, pass the provider tools to the AI SDK:
const result = await generateText({
model: provider.languageModel(),
prompt: "List files in /tmp",
tools: provider.tools,
});The actual tool name and arguments live inside
acp.acp_provider_agent_dynamic_tool's input and follow this structure:
export const providerAgentDynamicToolSchema = z.object({
toolCallId: z.string().describe("The unique ID of the tool call."),
toolName: z.string().describe("The name of the tool being called."),
args: z.record(z.any()).describe("The input arguments for the tool call."),
});You can import this constant using ACP_PROVIDER_AGENT_DYNAMIC_TOOL_NAME.
Raw stream parts (plan, diffs, terminals)
The provider emits additional data as raw stream parts.
Important: You must set
includeRawChunks: trueto receive raw stream parts, otherwise they will be filtered out by the AI SDK.
You can handle them directly in the stream:
const { fullStream } = streamText({
includeRawChunks: true, // Required to receive raw parts
model: provider.languageModel(),
prompt: "...",
});
for await (const chunk of fullStream) {
if (chunk.type === "raw") {
const data = JSON.parse(chunk.rawValue);
switch (data.type) {
case "plan":
// Plan steps: data.entries
break;
case "diff":
// File changes: data.path, data.oldText, data.newText, data.toolCallId
break;
case "terminal":
// Terminal output: data.terminalId, data.toolCallId
break;
}
}
}Or use messageMetadata to attach them to messages when streaming to UI:
const result = streamText({
includeRawChunks: true, // Required to receive raw parts
model: provider.languageModel(),
prompt: "...",
});
const response = result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
// Convert raw parts to metadata for easier UI access
if (part.type === "raw" && part.rawValue) {
const data = JSON.parse(part.rawValue as string);
switch (data.type) {
case "plan":
return { plan: data.entries };
case "diff":
return { diffs: [data] }; // Accumulate multiple diffs
case "terminal":
return { terminals: [data] }; // Accumulate terminal outputs
}
}
},
});
// In your UI component:
// message.metadata?.plan → plan entries
// message.metadata?.diffs → file changes
// message.metadata?.terminals → terminal outputsPerformance Optimization
For the best user experience, we recommend pre-initializing the session with your tools. Benchmarking shows this can reduce the Time to First Token (TTFT) by over 60%.
| Strategy | Connect Time | TTFT (Perceived) | | ------------------- | ------------ | ---------------- | | Standard (Lazy) | N/A | ~7.3s | | Pre-Initialized | ~2.3s | ~2.8s |
// 1. Create provider and tools
const provider = createACPProvider({/* ... */});
const tools = acpTools({/* ... */});
try {
// 2. Pre-initialize to warm up connection (saves ~5s)
await provider.initSession(tools);
// 3. Use in streamText (instant start)
await streamText({
model: provider.languageModel("model-id"),
tools, // Must use same tools instance
prompt: "Hello",
});
} finally {
provider.cleanup();
}Debugging
Set the ACP_AI_PROVIDER_DEBUG environment variable to enable debug logging:
# Enable debug logging
export ACP_AI_PROVIDER_DEBUG=1
# Run your script
npx tsx example.tsWhen enabled, raw ACP messages are logged to a temporary file:
[acp-ai-provider] Agent message log: /tmp/acp-ai-provider-xxx/agent-messages.ndjsonLimitations
- No token counting — ACP doesn't provide token usage information (it always returns 0).
- Dynamic tools are experimental — The
toolsparameter uses TCP callback which adds some complexity.
Related
License
MIT
