Skip to content

Commit 29264a3

Browse files
feat(openai): MCP tool approval (#11110)
## Background OpenAI's in built mcp tool allowed for human approval before executing it. This change integrates it in the existing tool definition. ## Summary - spec changed - flow changed to allow for passing provider executed tools responses - parse the `mcp_approval_request` from OpenAI's model and ensure it's processed properly - pass a proper `mcp_approval_response` back to the model. ## Manual Verification - [ ] UI example `http://localhost:3000/test-openai-responses-mcp-approval` - [ ] static examples added - [ ] test that non mcp (old) approval ui example still works ## Checklist - [x] Tests have been added / updated (for bug fixes / features) - [x] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) ## Related Issues Fixes #11001 --------- Co-authored-by: Lars Grammel <lars.grammel@gmail.com>
1 parent 7adea43 commit 29264a3

45 files changed

Lines changed: 5739 additions & 193 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/nasty-falcons-double.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ai-sdk/provider-utils': patch
3+
'@ai-sdk/openai': patch
4+
'ai': patch
5+
---
6+
7+
feat: add MCP tool approval
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
title: AI_InvalidToolApprovalError
3+
description: Learn how to fix AI_InvalidToolApprovalError
4+
---
5+
6+
# AI_InvalidToolApprovalError
7+
8+
This error occurs when a tool approval response references an unknown `approvalId`. No matching `tool-approval-request` was found in the message history.
9+
10+
## Properties
11+
12+
- `approvalId`: The approval ID that was not found
13+
- `message`: The error message
14+
15+
## Checking for this Error
16+
17+
You can check if an error is an instance of `AI_InvalidToolApprovalError` using:
18+
19+
```typescript
20+
import { InvalidToolApprovalError } from 'ai';
21+
22+
if (InvalidToolApprovalError.isInstance(error)) {
23+
// Handle the error
24+
}
25+
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: AI_ToolCallNotFoundForApprovalError
3+
description: Learn how to fix AI_ToolCallNotFoundForApprovalError
4+
---
5+
6+
# AI_ToolCallNotFoundForApprovalError
7+
8+
This error occurs when a tool approval request references a tool call that was not found. This can happen when processing provider-emitted approval requests (e.g., MCP flows) where the referenced tool call ID does not exist.
9+
10+
## Properties
11+
12+
- `toolCallId`: The tool call ID that was not found
13+
- `approvalId`: The approval request ID
14+
- `message`: The error message
15+
16+
## Checking for this Error
17+
18+
You can check if an error is an instance of `AI_ToolCallNotFoundForApprovalError` using:
19+
20+
```typescript
21+
import { ToolCallNotFoundForApprovalError } from 'ai';
22+
23+
if (ToolCallNotFoundForApprovalError.isInstance(error)) {
24+
// Handle the error
25+
}
26+
```

content/docs/07-reference/05-ai-sdk-errors/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ collapsed: true
1515
- [AI_InvalidMessageRoleError](/docs/reference/ai-sdk-errors/ai-invalid-message-role-error)
1616
- [AI_InvalidPromptError](/docs/reference/ai-sdk-errors/ai-invalid-prompt-error)
1717
- [AI_InvalidResponseDataError](/docs/reference/ai-sdk-errors/ai-invalid-response-data-error)
18+
- [AI_InvalidToolApprovalError](/docs/reference/ai-sdk-errors/ai-invalid-tool-approval-error)
1819
- [AI_InvalidToolInputError](/docs/reference/ai-sdk-errors/ai-invalid-tool-input-error)
1920
- [AI_JSONParseError](/docs/reference/ai-sdk-errors/ai-json-parse-error)
2021
- [AI_LoadAPIKeyError](/docs/reference/ai-sdk-errors/ai-load-api-key-error)
@@ -30,6 +31,7 @@ collapsed: true
3031
- [AI_NoSuchProviderError](/docs/reference/ai-sdk-errors/ai-no-such-provider-error)
3132
- [AI_NoSuchToolError](/docs/reference/ai-sdk-errors/ai-no-such-tool-error)
3233
- [AI_RetryError](/docs/reference/ai-sdk-errors/ai-retry-error)
34+
- [AI_ToolCallNotFoundForApprovalError](/docs/reference/ai-sdk-errors/ai-tool-call-not-found-for-approval-error)
3335
- [AI_ToolCallRepairError](/docs/reference/ai-sdk-errors/ai-tool-call-repair-error)
3436
- [AI_TooManyEmbeddingValuesForCallError](/docs/reference/ai-sdk-errors/ai-too-many-embedding-values-for-call-error)
3537
- [AI_TypeValidationError](/docs/reference/ai-sdk-errors/ai-type-validation-error)

content/providers/01-ai-sdk-providers/03-openai.mdx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,10 +569,26 @@ The MCP tool can be configured with:
569569

570570
Optional HTTP headers to include in requests to the MCP server.
571571

572+
- **requireApproval** _'always' | 'never' | object_ (optional)
573+
574+
Controls which MCP tool calls require user approval before execution. Can be:
575+
576+
- `'always'`: All MCP tool calls require approval
577+
- `'never'`: No MCP tool calls require approval (default)
578+
- An object with filters:
579+
```ts
580+
{
581+
never: {
582+
toolNames: ['safe_tool', 'another_safe_tool']; // Skip approval for these tools
583+
}
584+
}
585+
```
586+
587+
When approval is required, the model will return a `tool-approval-request` content part that you can use to prompt the user for approval. See [Human in the Loop](/cookbook/next/human-in-the-loop) for more details on implementing approval workflows.
588+
572589
<Note>
573-
The tool calls made by the model when using the OpenAI MCP tool are approved
574-
by default. Be sure to connect to only trusted MCP servers, who you trust to
575-
share your data with.
590+
When `requireApproval` is not set, tool calls are approved by default. Be sure
591+
to connect to only trusted MCP servers, who you trust to share your data with.
576592
</Note>
577593

578594
<Note>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createOpenAI } from '@ai-sdk/openai';
2+
import {
3+
generateText,
4+
ModelMessage,
5+
stepCountIs,
6+
ToolApprovalResponse,
7+
} from 'ai';
8+
import * as readline from 'node:readline/promises';
9+
import { run } from '../lib/run';
10+
11+
const terminal = readline.createInterface({
12+
input: process.stdin,
13+
output: process.stdout,
14+
});
15+
16+
const openai = createOpenAI();
17+
18+
run(async () => {
19+
const messages: ModelMessage[] = [];
20+
let approvals: ToolApprovalResponse[] = [];
21+
22+
while (true) {
23+
messages.push(
24+
approvals.length > 0
25+
? { role: 'tool', content: approvals }
26+
: { role: 'user', content: await terminal.question('You:\n') },
27+
);
28+
29+
if (approvals.length === 0) {
30+
const lastMessage = messages[messages.length - 1];
31+
if (
32+
lastMessage.role === 'user' &&
33+
typeof lastMessage.content === 'string' &&
34+
lastMessage.content.toLowerCase() === 'exit'
35+
) {
36+
terminal.close();
37+
break;
38+
}
39+
}
40+
41+
approvals = [];
42+
43+
const result = await generateText({
44+
model: openai.responses('gpt-5-mini'),
45+
system:
46+
'You are a helpful assistant that can shorten links. ' +
47+
'Use the MCP tools available to you to shorten links when needed. ' +
48+
'When a tool execution is not approved by the user, do not retry it. ' +
49+
'Just say that the tool execution was not approved.',
50+
tools: {
51+
mcp: openai.tools.mcp({
52+
serverLabel: 'zip1',
53+
serverUrl: 'https://zip1.io/mcp',
54+
serverDescription: 'Link shortener',
55+
requireApproval: 'always',
56+
}),
57+
},
58+
messages,
59+
stopWhen: stepCountIs(10),
60+
});
61+
62+
// Log raw response for debugging
63+
console.log('\n=== RAW RESPONSE ===');
64+
console.log('Steps:', result.steps.length);
65+
for (const [i, step] of result.steps.entries()) {
66+
console.log(`\n--- Step ${i + 1} ---`);
67+
console.log(
68+
'Content parts:',
69+
step.content.map(p => p.type),
70+
);
71+
for (const part of step.content) {
72+
if (
73+
part.type === 'tool-approval-request' ||
74+
part.type === 'tool-call' ||
75+
part.type === 'tool-result' ||
76+
part.type === 'tool-error'
77+
) {
78+
console.log(`Tool ${part.type}:`, JSON.stringify(part, null, 2));
79+
}
80+
}
81+
}
82+
console.log('\n=== END RAW RESPONSE ===\n');
83+
84+
for (const part of result.content) {
85+
if (part.type === 'tool-approval-request') {
86+
const answer = await terminal.question(
87+
`\nApprove MCP tool call? (y/n): `,
88+
);
89+
approvals.push({
90+
type: 'tool-approval-response',
91+
approvalId: part.approvalId,
92+
approved:
93+
answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes',
94+
});
95+
} else if (part.type === 'text') {
96+
console.log(`\nAssistant:\n${part.text}`);
97+
}
98+
}
99+
100+
messages.push(...result.response.messages);
101+
}
102+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createOpenAI } from '@ai-sdk/openai';
2+
import {
3+
ModelMessage,
4+
stepCountIs,
5+
streamText,
6+
ToolApprovalResponse,
7+
} from 'ai';
8+
import * as readline from 'node:readline/promises';
9+
import { run } from '../lib/run';
10+
11+
const terminal = readline.createInterface({
12+
input: process.stdin,
13+
output: process.stdout,
14+
});
15+
16+
const openai = createOpenAI();
17+
18+
run(async () => {
19+
const messages: ModelMessage[] = [];
20+
let approvals: ToolApprovalResponse[] = [];
21+
22+
while (true) {
23+
messages.push(
24+
approvals.length > 0
25+
? { role: 'tool', content: approvals }
26+
: { role: 'user', content: await terminal.question('You:\n') },
27+
);
28+
29+
if (approvals.length === 0) {
30+
const lastMessage = messages[messages.length - 1];
31+
if (
32+
lastMessage.role === 'user' &&
33+
typeof lastMessage.content === 'string' &&
34+
lastMessage.content.toLowerCase() === 'exit'
35+
) {
36+
terminal.close();
37+
break;
38+
}
39+
}
40+
41+
approvals = [];
42+
43+
const result = streamText({
44+
model: openai.responses('gpt-5-mini'),
45+
system:
46+
'You are a helpful assistant that can shorten links. ' +
47+
'Use the MCP tools available to you to shorten links when needed. ' +
48+
'When a tool execution is not approved by the user, do not retry it. ' +
49+
'Just say that the tool execution was not approved.',
50+
tools: {
51+
mcp: openai.tools.mcp({
52+
serverLabel: 'zip1',
53+
serverUrl: 'https://zip1.io/mcp',
54+
serverDescription: 'Link shortener',
55+
requireApproval: 'always',
56+
}),
57+
},
58+
messages,
59+
stopWhen: stepCountIs(10),
60+
});
61+
62+
// Stream text output
63+
process.stdout.write('\nAssistant: ');
64+
for await (const textPart of result.textStream) {
65+
process.stdout.write(textPart);
66+
}
67+
process.stdout.write('\n');
68+
69+
// Get final results
70+
const content = await result.content;
71+
const response = await result.response;
72+
73+
for (const part of content) {
74+
if (part.type === 'tool-approval-request') {
75+
const answer = await terminal.question(
76+
`\nApprove MCP tool call? (y/n): `,
77+
);
78+
approvals.push({
79+
type: 'tool-approval-response',
80+
approvalId: part.approvalId,
81+
approved:
82+
answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes',
83+
});
84+
}
85+
}
86+
87+
messages.push(...response.messages);
88+
}
89+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { ToolLoopAgent, InferAgentUIMessage } from 'ai';
3+
4+
export const openaiMCPApprovalAgent = new ToolLoopAgent({
5+
model: openai.responses('gpt-5'),
6+
instructions:
7+
'You are a helpful assistant that can shorten links. ' +
8+
'Use the MCP tools available to you to shorten links when needed. ' +
9+
'When a tool execution is not approved by the user, do not retry it. ' +
10+
'Just say that the tool execution was not approved.',
11+
tools: {
12+
mcp: openai.tools.mcp({
13+
serverLabel: 'zip1',
14+
serverUrl: 'https://zip1.io/mcp',
15+
serverDescription: 'Link shortener',
16+
requireApproval: 'always',
17+
}),
18+
},
19+
});
20+
21+
export type OpenAIMCPApprovalAgentUIMessage = InferAgentUIMessage<
22+
typeof openaiMCPApprovalAgent
23+
>;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
openaiMCPApprovalAgent,
3+
OpenAIMCPApprovalAgentUIMessage,
4+
} from '@/agent/openai-mcp-approval-agent';
5+
import { createAgentUIStreamResponse } from 'ai';
6+
7+
export const maxDuration = 60;
8+
9+
export type OpenAIResponsesMCPApprovalMessage = OpenAIMCPApprovalAgentUIMessage;
10+
11+
export async function POST(req: Request) {
12+
const body = await req.json();
13+
14+
return createAgentUIStreamResponse({
15+
agent: openaiMCPApprovalAgent,
16+
uiMessages: body.messages,
17+
});
18+
}

0 commit comments

Comments
 (0)