Skip to content

Commit 0580b9b

Browse files
authored
Add remote MCP server (Streamable HTTP) support; fixes #78 (#80)
* Add remote MCP server (Streamable HTTP) support; fixes #78 * Fix tests * Add npm command * Add changeset * simplify the example
1 parent 2fae25c commit 0580b9b

File tree

13 files changed

+299
-10
lines changed

13 files changed

+299
-10
lines changed

.changeset/crazy-coats-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-core': patch
3+
---
4+
5+
Add remote MCP server (Streamable HTTP) support

examples/mcp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"scripts": {
1010
"build-check": "tsc --noEmit",
1111
"start:stdio": "tsx filesystem-example.ts",
12+
"start:streamable-http": "tsx streamable-http-example.ts",
1213
"start:hosted-mcp-on-approval": "tsx hosted-mcp-on-approval.ts",
1314
"start:hosted-mcp-human-in-the-loop": "tsx hosted-mcp-human-in-the-loop.ts",
1415
"start:hosted-mcp-simple": "tsx hosted-mcp-simple.ts"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Agent, run, MCPServerStreamableHttp, withTrace } from '@openai/agents';
2+
3+
async function main() {
4+
const mcpServer = new MCPServerStreamableHttp({
5+
url: 'https://gitmcp.io/openai/codex',
6+
name: 'GitMCP Documentation Server',
7+
});
8+
const agent = new Agent({
9+
name: 'GitMCP Assistant',
10+
instructions: 'Use the tools to respond to user requests.',
11+
mcpServers: [mcpServer],
12+
});
13+
14+
try {
15+
await withTrace('GitMCP Documentation Server Example', async () => {
16+
await mcpServer.connect();
17+
const result = await run(
18+
agent,
19+
'Which language is this repo written in?',
20+
);
21+
console.log(result.finalOutput);
22+
});
23+
} finally {
24+
await mcpServer.close();
25+
}
26+
}
27+
28+
main().catch((err) => {
29+
console.error(err);
30+
process.exit(1);
31+
});

packages/agents-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export {
7171
invalidateServerToolsCache,
7272
MCPServer,
7373
MCPServerStdio,
74+
MCPServerStreamableHttp,
7475
} from './mcp';
7576
export {
7677
Model,

packages/agents-core/src/mcp.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { FunctionTool, tool, Tool } from './tool';
22
import { UserError } from './errors';
3-
import { MCPServerStdio as UnderlyingMCPServerStdio } from '@openai/agents-core/_shims';
3+
import {
4+
MCPServerStdio as UnderlyingMCPServerStdio,
5+
MCPServerStreamableHttp as UnderlyingMCPServerStreamableHttp,
6+
} from '@openai/agents-core/_shims';
47
import { getCurrentSpan, withMCPListToolsSpan } from './tracing';
58
import { logger as globalLogger, getLogger, Logger } from './logger';
69
import debug from 'debug';
@@ -15,6 +18,9 @@ import {
1518
export const DEFAULT_STDIO_MCP_CLIENT_LOGGER_NAME =
1619
'openai-agents:stdio-mcp-client';
1720

21+
export const DEFAULT_STREAMABLE_HTTP_MCP_CLIENT_LOGGER_NAME =
22+
'openai-agents:streamable-http-mcp-client';
23+
1824
/**
1925
* Interface for MCP server implementations.
2026
* Provides methods for connecting, listing tools, calling tools, and cleanup.
@@ -63,6 +69,39 @@ export abstract class BaseMCPServerStdio implements MCPServer {
6369
}
6470
}
6571

72+
export abstract class BaseMCPServerStreamableHttp implements MCPServer {
73+
public cacheToolsList: boolean;
74+
protected _cachedTools: any[] | undefined = undefined;
75+
76+
protected logger: Logger;
77+
constructor(options: MCPServerStreamableHttpOptions) {
78+
this.logger =
79+
options.logger ??
80+
getLogger(DEFAULT_STREAMABLE_HTTP_MCP_CLIENT_LOGGER_NAME);
81+
this.cacheToolsList = options.cacheToolsList ?? true;
82+
}
83+
84+
abstract get name(): string;
85+
abstract connect(): Promise<void>;
86+
abstract close(): Promise<void>;
87+
abstract listTools(): Promise<any[]>;
88+
abstract callTool(
89+
_toolName: string,
90+
_args: Record<string, unknown> | null,
91+
): Promise<CallToolResultContent>;
92+
93+
/**
94+
* Logs a debug message when debug logging is enabled.
95+
* @param buildMessage A function that returns the message to log.
96+
*/
97+
protected debugLog(buildMessage: () => string): void {
98+
if (debug.enabled(this.logger.namespace)) {
99+
// only when this is true, the function to build the string is called
100+
this.logger.debug(buildMessage());
101+
}
102+
}
103+
}
104+
66105
/**
67106
* Minimum MCP tool data definition.
68107
* This type definition does not intend to cover all possible properties.
@@ -116,6 +155,40 @@ export class MCPServerStdio extends BaseMCPServerStdio {
116155
return this.underlying.callTool(toolName, args);
117156
}
118157
}
158+
159+
export class MCPServerStreamableHttp extends BaseMCPServerStreamableHttp {
160+
private underlying: UnderlyingMCPServerStreamableHttp;
161+
constructor(options: MCPServerStreamableHttpOptions) {
162+
super(options);
163+
this.underlying = new UnderlyingMCPServerStreamableHttp(options);
164+
}
165+
get name(): string {
166+
return this.underlying.name;
167+
}
168+
connect(): Promise<void> {
169+
return this.underlying.connect();
170+
}
171+
close(): Promise<void> {
172+
return this.underlying.close();
173+
}
174+
async listTools(): Promise<MCPTool[]> {
175+
if (this.cacheToolsList && this._cachedTools) {
176+
return this._cachedTools;
177+
}
178+
const tools = await this.underlying.listTools();
179+
if (this.cacheToolsList) {
180+
this._cachedTools = tools;
181+
}
182+
return tools;
183+
}
184+
callTool(
185+
toolName: string,
186+
args: Record<string, unknown> | null,
187+
): Promise<CallToolResultContent> {
188+
return this.underlying.callTool(toolName, args);
189+
}
190+
}
191+
119192
/**
120193
* Fetches and flattens all tools from multiple MCP servers.
121194
* Logs and skips any servers that fail to respond.
@@ -292,6 +365,25 @@ export type MCPServerStdioOptions =
292365
| DefaultMCPServerStdioOptions
293366
| FullCommandMCPServerStdioOptions;
294367

368+
export interface MCPServerStreamableHttpOptions {
369+
url: string;
370+
cacheToolsList?: boolean;
371+
clientSessionTimeoutSeconds?: number;
372+
name?: string;
373+
logger?: Logger;
374+
375+
// ----------------------------------------------------
376+
// OAuth
377+
// import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
378+
authProvider?: any;
379+
// RequestInit
380+
requestInit?: any;
381+
// import { StreamableHTTPReconnectionOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
382+
reconnectionOptions?: any;
383+
sessionId?: string;
384+
// ----------------------------------------------------
385+
}
386+
295387
/**
296388
* Represents a JSON-RPC request message.
297389
*/

packages/agents-core/src/shims/mcp-stdio/browser.ts renamed to packages/agents-core/src/shims/mcp-server/browser.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
BaseMCPServerStdio,
3+
BaseMCPServerStreamableHttp,
34
CallToolResultContent,
45
MCPServerStdioOptions,
6+
MCPServerStreamableHttpOptions,
57
MCPTool,
68
} from '../../mcp';
79

@@ -28,3 +30,27 @@ export class MCPServerStdio extends BaseMCPServerStdio {
2830
throw new Error('Method not implemented.');
2931
}
3032
}
33+
34+
export class MCPServerStreamableHttp extends BaseMCPServerStreamableHttp {
35+
constructor(params: MCPServerStreamableHttpOptions) {
36+
super(params);
37+
}
38+
get name(): string {
39+
return 'MCPServerStdio';
40+
}
41+
connect(): Promise<void> {
42+
throw new Error('Method not implemented.');
43+
}
44+
close(): Promise<void> {
45+
throw new Error('Method not implemented.');
46+
}
47+
listTools(): Promise<MCPTool[]> {
48+
throw new Error('Method not implemented.');
49+
}
50+
callTool(
51+
_toolName: string,
52+
_args: Record<string, unknown> | null,
53+
): Promise<CallToolResultContent> {
54+
throw new Error('Method not implemented.');
55+
}
56+
}

packages/agents-core/src/shims/mcp-stdio/node.ts renamed to packages/agents-core/src/shims/mcp-server/node.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
22

33
import {
44
BaseMCPServerStdio,
5+
BaseMCPServerStreamableHttp,
56
CallToolResultContent,
67
DefaultMCPServerStdioOptions,
78
InitializeResult,
89
MCPServerStdioOptions,
10+
MCPServerStreamableHttpOptions,
911
MCPTool,
1012
invalidateServerToolsCache,
1113
} from '../../mcp';
@@ -154,3 +156,120 @@ export class NodeMCPServerStdio extends BaseMCPServerStdio {
154156
}
155157
}
156158
}
159+
160+
export class NodeMCPServerStreamableHttp extends BaseMCPServerStreamableHttp {
161+
protected session: Client | null = null;
162+
protected _cacheDirty = true;
163+
protected _toolsList: any[] = [];
164+
protected serverInitializeResult: InitializeResult | null = null;
165+
protected clientSessionTimeoutSeconds?: number;
166+
167+
params: MCPServerStreamableHttpOptions;
168+
private _name: string;
169+
private transport: any = null;
170+
171+
constructor(params: MCPServerStreamableHttpOptions) {
172+
super(params);
173+
this.clientSessionTimeoutSeconds = params.clientSessionTimeoutSeconds ?? 5;
174+
this.params = params;
175+
this._name = params.name || `streamable-http: ${this.params.url}`;
176+
}
177+
178+
async connect(): Promise<void> {
179+
try {
180+
const { StreamableHTTPClientTransport } = await import(
181+
'@modelcontextprotocol/sdk/client/streamableHttp.js'
182+
).catch(failedToImport);
183+
const { Client } = await import(
184+
'@modelcontextprotocol/sdk/client/index.js'
185+
).catch(failedToImport);
186+
this.transport = new StreamableHTTPClientTransport(
187+
new URL(this.params.url),
188+
{
189+
authProvider: this.params.authProvider,
190+
requestInit: this.params.requestInit,
191+
reconnectionOptions: this.params.reconnectionOptions,
192+
sessionId: this.params.sessionId,
193+
},
194+
);
195+
this.session = new Client({
196+
name: this._name,
197+
version: '1.0.0', // You may want to make this configurable
198+
});
199+
await this.session.connect(this.transport);
200+
this.serverInitializeResult = {
201+
serverInfo: { name: this._name, version: '1.0.0' },
202+
} as InitializeResult;
203+
} catch (e) {
204+
this.logger.error('Error initializing MCP server:', e);
205+
await this.close();
206+
throw e;
207+
}
208+
this.debugLog(() => `Connected to MCP server: ${this._name}`);
209+
}
210+
211+
invalidateToolsCache() {
212+
invalidateServerToolsCache(this.name);
213+
this._cacheDirty = true;
214+
}
215+
216+
// The response element type is intentionally left as `any` to avoid explosing MCP SDK type dependencies.
217+
async listTools(): Promise<MCPTool[]> {
218+
const { ListToolsResultSchema } = await import(
219+
'@modelcontextprotocol/sdk/types.js'
220+
).catch(failedToImport);
221+
if (!this.session) {
222+
throw new Error(
223+
'Server not initialized. Make sure you call connect() first.',
224+
);
225+
}
226+
if (this.cacheToolsList && !this._cacheDirty && this._toolsList) {
227+
return this._toolsList;
228+
}
229+
this._cacheDirty = false;
230+
const response = await this.session.listTools();
231+
this.debugLog(() => `Listed tools: ${JSON.stringify(response)}`);
232+
this._toolsList = ListToolsResultSchema.parse(response).tools;
233+
return this._toolsList;
234+
}
235+
236+
async callTool(
237+
toolName: string,
238+
args: Record<string, unknown> | null,
239+
): Promise<CallToolResultContent> {
240+
const { CallToolResultSchema } = await import(
241+
'@modelcontextprotocol/sdk/types.js'
242+
).catch(failedToImport);
243+
if (!this.session) {
244+
throw new Error(
245+
'Server not initialized. Make sure you call connect() first.',
246+
);
247+
}
248+
const response = await this.session.callTool({
249+
name: toolName,
250+
arguments: args ?? {},
251+
});
252+
const parsed = CallToolResultSchema.parse(response);
253+
const result = parsed.content;
254+
this.debugLog(
255+
() =>
256+
`Called tool ${toolName} (args: ${JSON.stringify(args)}, result: ${JSON.stringify(result)})`,
257+
);
258+
return result as CallToolResultContent;
259+
}
260+
261+
get name() {
262+
return this._name;
263+
}
264+
265+
async close(): Promise<void> {
266+
if (this.transport) {
267+
await this.transport.close();
268+
this.transport = null;
269+
}
270+
if (this.session) {
271+
await this.session.close();
272+
this.session = null;
273+
}
274+
}
275+
}

packages/agents-core/src/shims/shims-browser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export function isTracingLoopRunningByDefault(): boolean {
109109
return false;
110110
}
111111

112-
export { MCPServerStdio } from './mcp-stdio/browser';
112+
export { MCPServerStdio, MCPServerStreamableHttp } from './mcp-server/browser';
113113

114114
class BrowserTimer implements Timer {
115115
constructor() {}

packages/agents-core/src/shims/shims-node.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ export function isTracingLoopRunningByDefault(): boolean {
4141
export function isBrowserEnvironment(): boolean {
4242
return false;
4343
}
44-
export { NodeMCPServerStdio as MCPServerStdio } from './mcp-stdio/node';
44+
export {
45+
NodeMCPServerStdio as MCPServerStdio,
46+
NodeMCPServerStreamableHttp as MCPServerStreamableHttp,
47+
} from './mcp-server/node';
4548

4649
export { clearTimeout } from 'node:timers';
4750

packages/agents-core/src/shims/shims-workerd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function isTracingLoopRunningByDefault(): boolean {
5454
/**
5555
* Right now Cloudflare Workers does not support MCP
5656
*/
57-
export { MCPServerStdio } from './mcp-stdio/browser';
57+
export { MCPServerStdio, MCPServerStreamableHttp } from './mcp-server/browser';
5858

5959
export { clearTimeout, setTimeout } from 'node:timers';
6060

0 commit comments

Comments
 (0)