Skip to content

Commit c78a2c4

Browse files
vrtnisseratch
andcommitted
Add MCP server tool filtering support to agents-js (#164)
--------- Co-authored-by: Kazuhiro Sera <[email protected]>
1 parent cd24608 commit c78a2c4

File tree

18 files changed

+837
-23
lines changed

18 files changed

+837
-23
lines changed

.changeset/hungry-suns-search.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@openai/agents-realtime': patch
3+
'@openai/agents-core': patch
4+
---
5+
6+
agents-core, agents-realtime: add MCP tool-filtering support (fixes #162)

docs/src/content/docs/guides/mcp.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import hostedStreamExample from '../../../../../examples/docs/mcp/hostedStream.t
1010
import hostedHITLExample from '../../../../../examples/docs/mcp/hostedHITL.ts?raw';
1111
import streamableHttpExample from '../../../../../examples/docs/mcp/streamableHttp.ts?raw';
1212
import stdioExample from '../../../../../examples/docs/mcp/stdio.ts?raw';
13+
import toolFilterExample from '../../../../../examples/docs/mcp/tool-filter.ts?raw';
1314

1415
The [**Model Context Protocol (MCP)**](https://modelcontextprotocol.io) is an open protocol that standardizes how applications provide tools and context to LLMs. From the MCP docs:
1516

@@ -97,6 +98,16 @@ For **Streamable HTTP** and **Stdio** servers, each time an `Agent` runs it may
9798

9899
Only enable this if you're confident the tool list won't change. To invalidate the cache later, call `invalidateToolsCache()` on the server instance.
99100

101+
### Tool filtering
102+
103+
You can restrict which tools are exposed from each server by passing either a static filter via `createMCPToolStaticFilter` or a custom function. Here’s a combined example showing both approaches:
104+
105+
<Code
106+
lang="typescript"
107+
code={toolFilterExample}
108+
title="Tool filtering"
109+
/>
110+
100111
## Further reading
101112

102113
- [Model Context Protocol](https://modelcontextprotocol.io/) – official specification.

examples/docs/mcp/tool-filter.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
MCPServerStdio,
3+
MCPServerStreamableHttp,
4+
createMCPToolStaticFilter,
5+
MCPToolFilterContext,
6+
} from '@openai/agents';
7+
8+
interface ToolFilterContext {
9+
allowAll: boolean;
10+
}
11+
12+
const server = new MCPServerStdio({
13+
fullCommand: 'my-server',
14+
toolFilter: createMCPToolStaticFilter({
15+
allowed: ['safe_tool'],
16+
blocked: ['danger_tool'],
17+
}),
18+
});
19+
20+
const dynamicServer = new MCPServerStreamableHttp({
21+
url: 'http://localhost:3000',
22+
toolFilter: async ({ runContext }: MCPToolFilterContext, tool) =>
23+
(runContext.context as ToolFilterContext).allowAll || tool.name !== 'admin',
24+
});

examples/mcp/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ Run the example from the repository root:
1212
```bash
1313
pnpm -F mcp start:stdio
1414
```
15+
16+
`tool-filter-example.ts` shows how to expose only a subset of server tools:
17+
18+
```bash
19+
pnpm -F mcp start:tool-filter
20+
```

examples/mcp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"start:streamable-http": "tsx streamable-http-example.ts",
1313
"start:hosted-mcp-on-approval": "tsx hosted-mcp-on-approval.ts",
1414
"start:hosted-mcp-human-in-the-loop": "tsx hosted-mcp-human-in-the-loop.ts",
15-
"start:hosted-mcp-simple": "tsx hosted-mcp-simple.ts"
15+
"start:hosted-mcp-simple": "tsx hosted-mcp-simple.ts",
16+
"start:tool-filter": "tsx tool-filter-example.ts"
1617
}
1718
}

examples/mcp/tool-filter-example.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
Agent,
3+
run,
4+
MCPServerStdio,
5+
createMCPToolStaticFilter,
6+
withTrace,
7+
} from '@openai/agents';
8+
import * as path from 'node:path';
9+
10+
async function main() {
11+
const samplesDir = path.join(__dirname, 'sample_files');
12+
const mcpServer = new MCPServerStdio({
13+
name: 'Filesystem Server with filter',
14+
fullCommand: `npx -y @modelcontextprotocol/server-filesystem ${samplesDir}`,
15+
toolFilter: createMCPToolStaticFilter({
16+
allowed: ['read_file', 'list_directory'],
17+
blocked: ['write_file'],
18+
}),
19+
});
20+
21+
await mcpServer.connect();
22+
23+
try {
24+
await withTrace('MCP Tool Filter Example', async () => {
25+
const agent = new Agent({
26+
name: 'MCP Assistant',
27+
instructions: 'Use the filesystem tools to answer questions.',
28+
mcpServers: [mcpServer],
29+
});
30+
31+
console.log('Listing sample files:');
32+
let result = await run(
33+
agent,
34+
'List the files in the sample_files directory.',
35+
);
36+
console.log(result.finalOutput);
37+
38+
console.log('\nAttempting to write a file (should be blocked):');
39+
result = await run(
40+
agent,
41+
'Create a file named sample_files/test.txt with the text "hello"',
42+
);
43+
console.log(result.finalOutput);
44+
});
45+
} finally {
46+
await mcpServer.close();
47+
}
48+
}
49+
50+
main().catch((err) => {
51+
console.error(err);
52+
process.exit(1);
53+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"examples:tools-computer-use": "pnpm -F tools start:computer-use",
3838
"examples:tools-file-search": "pnpm -F tools start:file-search",
3939
"examples:tools-web-search": "pnpm -F tools start:web-search",
40+
"examples:tool-filter": "tsx examples/mcp/tool-filter-example.ts",
4041
"ci:publish": "pnpm publish -r --no-git-checks",
4142
"bump-version": "changeset version && pnpm -F @openai/* prebuild",
4243
"prepare": "husky",

packages/agents-core/src/agent.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -531,9 +531,11 @@ export class Agent<
531531
* Fetches the available tools from the MCP servers.
532532
* @returns the MCP powered tools
533533
*/
534-
async getMcpTools(): Promise<Tool<TContext>[]> {
534+
async getMcpTools(
535+
runContext: RunContext<TContext>,
536+
): Promise<Tool<TContext>[]> {
535537
if (this.mcpServers.length > 0) {
536-
return getAllMcpTools(this.mcpServers);
538+
return getAllMcpTools(this.mcpServers, runContext, this, false);
537539
}
538540

539541
return [];
@@ -544,8 +546,10 @@ export class Agent<
544546
*
545547
* @returns all configured tools
546548
*/
547-
async getAllTools(): Promise<Tool<TContext>[]> {
548-
return [...(await this.getMcpTools()), ...this.tools];
549+
async getAllTools(
550+
runContext: RunContext<TContext>,
551+
): Promise<Tool<TContext>[]> {
552+
return [...(await this.getMcpTools(runContext)), ...this.tools];
549553
}
550554

551555
/**

packages/agents-core/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export {
7474
MCPServerStdio,
7575
MCPServerStreamableHttp,
7676
} from './mcp';
77+
export {
78+
MCPToolFilterCallable,
79+
MCPToolFilterContext,
80+
MCPToolFilterStatic,
81+
createMCPToolStaticFilter,
82+
} from './mcpUtil';
7783
export {
7884
Model,
7985
ModelProvider,

packages/agents-core/src/mcp.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import {
1414
JsonObjectSchemaStrict,
1515
UnknownContext,
1616
} from './types';
17+
import type { MCPToolFilterCallable, MCPToolFilterStatic } from './mcpUtil';
18+
import type { RunContext } from './runContext';
19+
import type { Agent } from './agent';
1720

1821
export const DEFAULT_STDIO_MCP_CLIENT_LOGGER_NAME =
1922
'openai-agents:stdio-mcp-client';
@@ -27,6 +30,7 @@ export const DEFAULT_STREAMABLE_HTTP_MCP_CLIENT_LOGGER_NAME =
2730
*/
2831
export interface MCPServer {
2932
cacheToolsList: boolean;
33+
toolFilter?: MCPToolFilterCallable | MCPToolFilterStatic;
3034
connect(): Promise<void>;
3135
readonly name: string;
3236
close(): Promise<void>;
@@ -41,12 +45,14 @@ export interface MCPServer {
4145
export abstract class BaseMCPServerStdio implements MCPServer {
4246
public cacheToolsList: boolean;
4347
protected _cachedTools: any[] | undefined = undefined;
48+
public toolFilter?: MCPToolFilterCallable | MCPToolFilterStatic;
4449

4550
protected logger: Logger;
4651
constructor(options: MCPServerStdioOptions) {
4752
this.logger =
4853
options.logger ?? getLogger(DEFAULT_STDIO_MCP_CLIENT_LOGGER_NAME);
4954
this.cacheToolsList = options.cacheToolsList ?? false;
55+
this.toolFilter = options.toolFilter;
5056
}
5157

5258
abstract get name(): string;
@@ -74,13 +80,15 @@ export abstract class BaseMCPServerStdio implements MCPServer {
7480
export abstract class BaseMCPServerStreamableHttp implements MCPServer {
7581
public cacheToolsList: boolean;
7682
protected _cachedTools: any[] | undefined = undefined;
83+
public toolFilter?: MCPToolFilterCallable | MCPToolFilterStatic;
7784

7885
protected logger: Logger;
7986
constructor(options: MCPServerStreamableHttpOptions) {
8087
this.logger =
8188
options.logger ??
8289
getLogger(DEFAULT_STREAMABLE_HTTP_MCP_CLIENT_LOGGER_NAME);
8390
this.cacheToolsList = options.cacheToolsList ?? false;
91+
this.toolFilter = options.toolFilter;
8492
}
8593

8694
abstract get name(): string;
@@ -204,13 +212,17 @@ export class MCPServerStreamableHttp extends BaseMCPServerStreamableHttp {
204212
*/
205213
export async function getAllMcpFunctionTools<TContext = UnknownContext>(
206214
mcpServers: MCPServer[],
215+
runContext: RunContext<TContext>,
216+
agent: Agent<any, any>,
207217
convertSchemasToStrict = false,
208218
): Promise<Tool<TContext>[]> {
209219
const allTools: Tool<TContext>[] = [];
210220
const toolNames = new Set<string>();
211221
for (const server of mcpServers) {
212222
const serverTools = await getFunctionToolsFromServer(
213223
server,
224+
runContext,
225+
agent,
214226
convertSchemasToStrict,
215227
);
216228
const serverToolNames = new Set(serverTools.map((t) => t.name));
@@ -242,6 +254,8 @@ export async function invalidateServerToolsCache(serverName: string) {
242254
*/
243255
async function getFunctionToolsFromServer<TContext = UnknownContext>(
244256
server: MCPServer,
257+
runContext: RunContext<TContext>,
258+
agent: Agent<any, any>,
245259
convertSchemasToStrict: boolean,
246260
): Promise<FunctionTool<TContext, any, unknown>[]> {
247261
if (server.cacheToolsList && _cachedTools[server.name]) {
@@ -251,7 +265,53 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>(
251265
}
252266
return withMCPListToolsSpan(
253267
async (span) => {
254-
const mcpTools = await server.listTools();
268+
const fetchedMcpTools = await server.listTools();
269+
const mcpTools: MCPTool[] = [];
270+
const context = {
271+
runContext,
272+
agent,
273+
serverName: server.name,
274+
};
275+
for (const tool of fetchedMcpTools) {
276+
const filter = server.toolFilter;
277+
if (filter) {
278+
if (filter && typeof filter === 'function') {
279+
const filtered = await filter(context, tool);
280+
if (!filtered) {
281+
globalLogger.debug(
282+
`MCP Tool (server: ${server.name}, tool: ${tool.name}) is blocked by the callable filter.`,
283+
);
284+
continue; // skip this tool
285+
}
286+
} else {
287+
const allowedToolNames = filter.allowedToolNames ?? [];
288+
const blockedToolNames = filter.blockedToolNames ?? [];
289+
if (allowedToolNames.length > 0 || blockedToolNames.length > 0) {
290+
const allowed =
291+
allowedToolNames.length > 0
292+
? allowedToolNames.includes(tool.name)
293+
: true;
294+
const blocked =
295+
blockedToolNames.length > 0
296+
? blockedToolNames.includes(tool.name)
297+
: false;
298+
if (!allowed || blocked) {
299+
if (blocked) {
300+
globalLogger.debug(
301+
`MCP Tool (server: ${server.name}, tool: ${tool.name}) is blocked by the static filter.`,
302+
);
303+
} else if (!allowed) {
304+
globalLogger.debug(
305+
`MCP Tool (server: ${server.name}, tool: ${tool.name}) is not allowed by the static filter.`,
306+
);
307+
}
308+
continue; // skip this tool
309+
}
310+
}
311+
}
312+
}
313+
mcpTools.push(tool);
314+
}
255315
span.spanData.result = mcpTools.map((t) => t.name);
256316
const tools: FunctionTool<TContext, any, string>[] = mcpTools.map((t) =>
257317
mcpToFunctionTool(t, server, convertSchemasToStrict),
@@ -270,9 +330,16 @@ async function getFunctionToolsFromServer<TContext = UnknownContext>(
270330
*/
271331
export async function getAllMcpTools<TContext = UnknownContext>(
272332
mcpServers: MCPServer[],
333+
runContext: RunContext<TContext>,
334+
agent: Agent<TContext, any>,
273335
convertSchemasToStrict = false,
274336
): Promise<Tool<TContext>[]> {
275-
return getAllMcpFunctionTools(mcpServers, convertSchemasToStrict);
337+
return getAllMcpFunctionTools(
338+
mcpServers,
339+
runContext,
340+
agent,
341+
convertSchemasToStrict,
342+
);
276343
}
277344

278345
/**
@@ -363,6 +430,7 @@ export interface BaseMCPServerStdioOptions {
363430
encoding?: string;
364431
encodingErrorHandler?: 'strict' | 'ignore' | 'replace';
365432
logger?: Logger;
433+
toolFilter?: MCPToolFilterCallable | MCPToolFilterStatic;
366434
}
367435
export interface DefaultMCPServerStdioOptions
368436
extends BaseMCPServerStdioOptions {
@@ -383,6 +451,7 @@ export interface MCPServerStreamableHttpOptions {
383451
clientSessionTimeoutSeconds?: number;
384452
name?: string;
385453
logger?: Logger;
454+
toolFilter?: MCPToolFilterCallable | MCPToolFilterStatic;
386455

387456
// ----------------------------------------------------
388457
// OAuth

0 commit comments

Comments
 (0)