Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **No false warning when combining `allowedTools` and `disallowedTools`; tool permission docs corrected** ([#212](https://github.com/ben-vargas/ai-sdk-provider-claude-code/issues/212)) - Validation warned "Both allowedTools and disallowedTools are specified. Only allowedTools will be used." That was never true: the provider forwards both lists unchanged and the CLI applies both, with matching deny rules taking precedence. The warning is removed (forwarding is unchanged). The README, `ClaudeCodeSettings` JSDoc, guides, and `tool-management.ts` example no longer say the lists can't be combined or that `allowedTools` is an allowlist. `allowedTools` only pre-approves tools (they run without prompting); use `tools` to restrict which built-in tools exist (`tools: []` disables them all), and control MCP tools separately.
- **Tool permission examples are accurate** ([#212](https://github.com/ben-vargas/ai-sdk-provider-claude-code/issues/212)) - `examples/mcp-filesystem.ts` claimed `allowedTools` restricted it to read-only MCP tools while using `bypassPermissions`, which approves every tool, so `write_file` succeeded. It now uses `permissionMode: 'dontAsk'` with only the read tools pre-approved, and ends with a `write_file` attempt that is denied. The guides and `tool-management.ts` no longer list `Delete`, which was never a Claude Code tool, or `NotebookRead`, which current CLIs no longer have. The "read-only mode" pattern no longer denies only `Write`/`Edit` (Bash could still write); it now uses `tools: ['Read', 'Grep', 'Glob']` (in the 0.x guide, a deny list that includes `Bash`).

## [3.7.1] - 2026-10-25

Expand Down
9 changes: 5 additions & 4 deletions docs/ai-sdk-v4/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,14 +279,15 @@ const mixedClaude = createClaudeCode({
#### Denying tools with disallowedTools

```typescript
// Block dangerous operations
// Remove Write, Edit and MultiEdit, and deny matching rm/sudo commands
// (Bash and NotebookEdit stay available, so this is not read-only)
const safeClaude = createClaudeCode({
defaultSettings: {
disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'],
disallowedTools: ['Write', 'Edit', 'MultiEdit', 'Bash(rm:*)', 'Bash(sudo:*)'],
},
});

// Block all Bash and MCP write operations
// Remove Bash and specific MCP write tools
const restrictedClaude = createClaudeCode({
defaultSettings: {
disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'],
Expand Down Expand Up @@ -336,7 +337,7 @@ const { text } = await generateText({

**Common Patterns**:

- Read-only mode: `disallowedTools: ['Write', 'Edit', 'Delete']`
- Read-only built-in tools: `disallowedTools: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash']` removes the built-in tools of that CLI generation that can modify files. Bash must be included, because shell commands can write files. MCP tools are not covered; deny their write tools separately
- No shell access: `disallowedTools: ['Bash']`
- Safe git: `allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)']`
- No MCP: `disallowedTools: ['mcp__*']`
Expand Down
18 changes: 10 additions & 8 deletions docs/ai-sdk-v5/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,18 +468,19 @@ Three settings control tools, and they can be combined:

#### Tool Types

- **Built-in tools**: `Bash`, `Edit`, `Read`, `Write`, `LS`, `Grep`, etc.
- **Built-in tools**: `Bash`, `Edit`, `Read`, `Write`, `Grep`, `Glob`, `NotebookEdit`, `WebFetch`, etc.
- **MCP tools**: `mcp__serverName__toolName` format

#### Restricting built-in tools with tools
#### Restricting built-in tools with tools (provider 2.3.0+ on 2.x, or 3.1.0+)

```typescript
import { createClaudeCode } from 'ai-sdk-provider-claude-code';

// Only these built-in tools exist in the session
// Read-only built-ins: only these built-in tools exist in the session
// (MCP tools are controlled separately)
const readOnlyClaude = createClaudeCode({
defaultSettings: {
tools: ['Read', 'LS', 'Grep'],
tools: ['Read', 'Grep', 'Glob'],
},
});

Expand Down Expand Up @@ -517,14 +518,15 @@ const mixedClaude = createClaudeCode({
#### Denying tools with disallowedTools

```typescript
// Block dangerous operations
// Remove the file-editing tools and deny matching rm/sudo commands
// (Bash itself stays available, so this is not read-only)
const safeClaude = createClaudeCode({
defaultSettings: {
disallowedTools: ['Write', 'Edit', 'Delete', 'Bash(rm:*)', 'Bash(sudo:*)'],
disallowedTools: ['Write', 'Edit', 'NotebookEdit', 'Bash(rm:*)', 'Bash(sudo:*)'],
},
});

// Block all Bash and MCP write operations
// Remove Bash and specific MCP write tools
const restrictedClaude = createClaudeCode({
defaultSettings: {
disallowedTools: ['Bash', 'mcp__filesystem__write_file', 'mcp__git__commit', 'mcp__git__push'],
Expand Down Expand Up @@ -579,7 +581,7 @@ const result = await generateText({

**Common Patterns**:

- Read-only mode: `disallowedTools: ['Write', 'Edit', 'Delete']`
- Read-only built-in tools (provider 2.3.0+ on 2.x, or 3.1.0+): `tools: ['Read', 'Grep', 'Glob']`. Denying only the file tools (`disallowedTools: ['Write', 'Edit']`) is not read-only, because tools that run shell commands, such as `Bash`, can still write files. `tools` does not restrict MCP tools; restrict those separately by listing the server's write tools in `disallowedTools` (for example `mcp__filesystem__write_file`). On provider 3.2.0+ you can instead use `permissionMode: 'dontAsk'` with only the MCP read tools in `allowedTools`, which denies any other call that would require approval (see `examples/mcp-filesystem.ts`)
- No shell access: `disallowedTools: ['Bash']`
- Safe git: `allowedTools: ['Bash(git log:*)', 'Bash(git diff:*)']`
- No MCP: `disallowedTools: ['mcp__*']`
Expand Down
4 changes: 2 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ npx tsx examples/sdk-tools-callbacks.ts

### 16. External MCP Server (`mcp-filesystem.ts`)

**Purpose**: Run an external MCP server over stdio using the official `@modelcontextprotocol/server-filesystem` package.
**Purpose**: Run an external MCP server over stdio using the official `@modelcontextprotocol/server-filesystem` package, restricted to read-only access. The run ends with a `write_file` attempt that should be denied.

```bash
npx tsx examples/mcp-filesystem.ts
Expand All @@ -223,7 +223,7 @@ Optional: pass a specific directory to scope filesystem access:
npx tsx examples/mcp-filesystem.ts /absolute/path/to/inspect
```

**Key concepts**: `mcpServers` stdio config, external MCP transport, MCP tool allowlists, read-only MCP permissions
**Key concepts**: `mcpServers` stdio config, external MCP transport, read-only MCP access via `permissionMode: 'dontAsk'` plus `allowedTools` pre-approving only the read tools (`bypassPermissions` would approve every tool, including MCP writes)

Tip: examples run with concise logs by default. Set `CLAUDE_EXAMPLE_VERBOSE=1` to see low-level provider debug traces.

Expand Down
209 changes: 149 additions & 60 deletions examples/mcp-filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* Example: External MCP Server (Filesystem)
*
* Demonstrates a working external MCP server configuration using the official
* @modelcontextprotocol/server-filesystem package via stdio.
* @modelcontextprotocol/server-filesystem package via stdio, restricted to
* read-only access: `permissionMode: 'dontAsk'` denies every tool call that
* would need approval, and `allowedTools` pre-approves only the read tools.
* The prompt ends with a write_file attempt to show that it is denied.
*
* Requirements:
* - npm run build
Expand All @@ -16,8 +19,8 @@
import { streamText } from 'ai';
import { createClaudeCode } from '../dist/index.js';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';

async function main() {
const verboseLogs = process.env.CLAUDE_EXAMPLE_VERBOSE === '1';
Expand All @@ -32,30 +35,22 @@ async function main() {

const pathInstruction = JSON.stringify(targetDir);
const fileInstruction = targetFile ? JSON.stringify(targetFile) : undefined;

const prompt = targetFile
? `Use MCP filesystem tools with ONLY these absolute paths:
1) Call list_allowed_directories.
2) Call list_directory with path ${pathInstruction} exactly.
3) Call read_text_file with path ${fileInstruction} exactly.
Do not call list_directory on "/" and do not access any path outside ${pathInstruction}.`
: `Use MCP filesystem tools with ONLY these absolute paths:
1) Call list_allowed_directories.
2) Call list_directory with path ${pathInstruction} exactly.
Do not call list_directory on "/" and do not access any path outside ${pathInstruction}.`;

const provider = createClaudeCode({
defaultSettings: {
cwd: targetDir,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
// 'dontAsk' denies any call that would need approval instead of
// prompting, so only pre-approved tools (and calls that never need
// approval) can run. Do not use 'bypassPermissions' here: it approves
// every tool, including the MCP write tools, regardless of allowedTools.
permissionMode: 'dontAsk',
mcpServers: {
filesystem: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', targetDir],
},
},
// Restrict to read-only filesystem MCP tools.
// Pre-approve only the read-only filesystem MCP tools. Under 'dontAsk',
// every other MCP tool (write_file, edit_file, move_file, ...) is denied.
allowedTools: [
'mcp__filesystem__list_allowed_directories',
'mcp__filesystem__list_directory',
Expand All @@ -74,56 +69,150 @@ Do not call list_directory on "/" and do not access any path outside ${pathInstr
console.log('Preferred file to read: none found in target directory (listing only)');
}

const result = streamText({
model: provider('sonnet', {
// stream events make it easier to confirm that MCP tool calls happened
streamingInput: 'always',
includePartialMessages: true,
verbose: verboseLogs,
maxToolResultSize: 200_000,
}),
prompt,
});
// The write_file probe targets a file inside a directory this run creates
// atomically, so the cleanup below can only ever remove what this run owns.
const probeDir = mkdtempSync(join(targetDir, 'mcp-write-probe-'));
const probeFile = join(probeDir, 'probe.txt');

// The probe passes only if the permission system denied this exact
// write_file call. A missing file alone proves nothing: Claude might not
// have attempted the write, or the server might have rejected it for
// another reason (for example, a path outside its allowed directories).
const probeCallIds = new Set<string>();
const deniedCallIds = new Set<string>();
let sawStreamError = false;
let probeWritten = false;

try {
// write_file is not pre-approved, so this attempt must be denied.
const writeInstruction = `Finally, call write_file to create ${JSON.stringify(probeFile)} with the content "probe", and report whether it was allowed.`;

const prompt = targetFile
? `Use MCP filesystem tools with ONLY these absolute paths:
1) Call list_allowed_directories.
2) Call list_directory with path ${pathInstruction} exactly.
3) Call read_text_file with path ${fileInstruction} exactly.
Do not call list_directory on "/" and do not access any path outside ${pathInstruction}.
${writeInstruction}`
: `Use MCP filesystem tools with ONLY these absolute paths:
1) Call list_allowed_directories.
2) Call list_directory with path ${pathInstruction} exactly.
Do not call list_directory on "/" and do not access any path outside ${pathInstruction}.
${writeInstruction}`;

console.log('Streaming output (with MCP tool events):\n');

const stream = result.fullStream as AsyncIterable<any>;
for await (const part of stream) {
switch (part.type) {
case 'tool-call':
console.log(`TOOL CALL: ${part.toolName} (${part.toolCallId})`);
break;
case 'tool-result':
console.log(`TOOL RESULT: ${part.toolName} (${part.toolCallId})`);
{
const output = part.result ?? part.output;
if (output !== undefined) {
console.dir(output, { depth: 6 });
} else {
console.log('(no structured tool output payload)');
const result = streamText({
model: provider('sonnet', {
// stream events make it easier to confirm that MCP tool calls happened
streamingInput: 'always',
includePartialMessages: true,
verbose: verboseLogs,
maxToolResultSize: 200_000,
}),
prompt,
});

console.log('Streaming output (with MCP tool events):\n');

for await (const part of result.fullStream as AsyncIterable<any>) {
switch (part.type) {
case 'tool-call':
console.log(`TOOL CALL: ${part.toolName} (${part.toolCallId})`);
if (isWriteTo(part.toolName, part.input, probeFile)) probeCallIds.add(part.toolCallId);
break;
case 'tool-result':
console.log(`TOOL RESULT: ${part.toolName} (${part.toolCallId})`);
{
const output = part.result ?? part.output;
if (output !== undefined) {
console.dir(output, { depth: 6 });
} else {
console.log('(no structured tool output payload)');
}
}
break;
case 'tool-error':
console.error(`TOOL ERROR: ${part.toolName} -> ${part.error}`);
break;
case 'text-delta': {
// AI SDK v6 fullStream carries the chunk in `text` (older builds used `delta`).
const chunk = part.delta ?? part.text;
if (typeof chunk === 'string') {
process.stdout.write(chunk);
}
break;
}
break;
case 'tool-error':
console.error(`TOOL ERROR: ${part.toolName} -> ${part.error}`);
break;
case 'text-delta': {
// AI SDK v6 fullStream carries the chunk in `text` (older builds used `delta`).
const chunk = part.delta ?? part.text;
if (typeof chunk === 'string') {
process.stdout.write(chunk);
}
break;
case 'finish-step':
// The provider reports permission denials with the denied tool call IDs.
for (const id of deniedToolCallIds(part.providerMetadata)) deniedCallIds.add(id);
break;
case 'error':
sawStreamError = true;
console.error('STREAM ERROR:', part.error);
break;
case 'finish':
console.log('\n\nDone.');
break;
default:
break;
}
case 'finish':
console.log('\n\nDone.');
break;
default:
break;
}
} finally {
// Runs even if the stream throws, so an unexpected write never lingers.
probeWritten = existsSync(probeFile);
rmSync(probeDir, { recursive: true, force: true });
if (probeWritten) {
process.exitCode = 1;
console.error(`\nFAIL: write_file created ${probeFile} (removed). Writes were NOT blocked.`);
}
}

if (probeWritten) return;
const inconclusive = (reason: string) => {
process.exitCode = 1;
console.error(`\nINCONCLUSIVE: ${reason}, so read-only enforcement was not demonstrated.`);
};
if (sawStreamError) {
inconclusive('the stream reported an error');
} else if (probeCallIds.size === 0) {
inconclusive('Claude did not attempt the write_file probe');
} else if (![...probeCallIds].some((id) => deniedCallIds.has(id))) {
inconclusive('the write_file probe was not denied by the permission system');
} else {
console.log(
`\nWrite blocked: the permission system denied write_file, and no probe file was created.`
);
}
}

// True when a tool call is the filesystem server's write_file targeting `path`.
function isWriteTo(toolName: string, input: unknown, path: string): boolean {
let args = input;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
return false;
}
}
return (
toolName === 'mcp__filesystem__write_file' &&
typeof args === 'object' &&
args !== null &&
(args as { path?: unknown }).path === path
);
}

// Tool call IDs from providerMetadata['claude-code'].permissionDenials.
function deniedToolCallIds(providerMetadata: unknown): string[] {
const denials = (
providerMetadata as { 'claude-code'?: { permissionDenials?: unknown } } | undefined
)?.['claude-code']?.permissionDenials;
if (!Array.isArray(denials)) return [];
return denials
.map((denial) => (denial as { toolUseId?: unknown }).toolUseId)
.filter((id): id is string => typeof id === 'string');
}

main().catch((error) => {
console.error('Example failed:', error);
process.exit(1);
Expand Down
Loading
Loading