@genkit-ai/mcp 1.33.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,13 +20,14 @@ import {
20
20
  type DynamicResourceAction,
21
21
  type ExecutablePrompt,
22
22
  type Genkit,
23
+ type MultipartToolAction,
23
24
  type PromptGenerateOptions,
24
25
  type ToolAction,
25
26
  } from 'genkit';
26
27
  import { logger } from 'genkit/logging';
27
28
  import { GenkitMcpClient, McpServerConfig } from './client.js';
28
29
 
29
- export interface McpHostOptions {
30
+ export interface McpHostOptions<M extends boolean = false> {
30
31
  /**
31
32
  * An optional client name for this MCP host. This name is advertised to MCP Servers
32
33
  * as the connecting client name. Defaults to 'genkit-mcp'.
@@ -52,13 +53,19 @@ export interface McpHostOptions {
52
53
  */
53
54
  rawToolResponses?: boolean;
54
55
 
56
+ /** If true, tools will be registered as multipart tool.v2 actions. */
57
+ multipart?: M;
58
+
55
59
  /**
56
60
  * When provided, each connected MCP server will be sent the roots specified here. Overridden by any specific roots sent in the `mcpServers` config for a given server.
57
61
  */
58
62
  roots?: Root[];
59
63
  }
60
64
 
61
- export type McpHostOptionsWithCache = Omit<McpHostOptions, 'name'> & {
65
+ export type McpHostOptionsWithCache<M extends boolean = false> = Omit<
66
+ McpHostOptions<M>,
67
+ 'name'
68
+ > & {
62
69
  /**
63
70
  * A client name for this MCP host. This name is advertised to MCP Servers
64
71
  * as the connecting client name.
@@ -92,9 +99,9 @@ interface ClientState {
92
99
  * It allows for dynamic registration of tools from all connected and enabled MCP servers
93
100
  * into a Genkit instance.
94
101
  */
95
- export class GenkitMcpHost {
102
+ export class GenkitMcpHost<Multipart extends boolean = false> {
96
103
  name: string;
97
- private _clients: Record<string, GenkitMcpClient> = {};
104
+ private _clients: Record<string, GenkitMcpClient<Multipart>> = {};
98
105
  private _clientStates: Record<string, ClientState> = {};
99
106
  private _readyListeners: {
100
107
  resolve: () => void;
@@ -104,10 +111,12 @@ export class GenkitMcpHost {
104
111
  private _dynamicActionProvider: DynamicActionProviderAction | undefined;
105
112
  private roots: Root[] | undefined;
106
113
  rawToolResponses?: boolean;
114
+ multipart?: Multipart;
107
115
 
108
- constructor(options: McpHostOptions) {
116
+ constructor(options: McpHostOptions<Multipart>) {
109
117
  this.name = options.name || 'genkit-mcp';
110
118
  this.rawToolResponses = options.rawToolResponses;
119
+ this.multipart = options.multipart;
111
120
  this.roots = options.roots;
112
121
 
113
122
  if (options.mcpServers) {
@@ -165,11 +174,12 @@ export class GenkitMcpHost {
165
174
  `[MCP Host] Connecting to MCP server '${serverName}' in host '${this.name}'.`
166
175
  );
167
176
  try {
168
- const client = new GenkitMcpClient({
177
+ const client = new GenkitMcpClient<Multipart>({
169
178
  name: this.name,
170
179
  serverName: serverName,
171
180
  mcpServer: { ...config, roots: config.roots || this.roots },
172
181
  rawToolResponses: this.rawToolResponses,
182
+ multipart: this.multipart,
173
183
  });
174
184
  this._clients[serverName] = client;
175
185
  } catch (e) {
@@ -353,9 +363,13 @@ export class GenkitMcpHost {
353
363
  * @returns A Promise that resolves to an array of `ToolAction` from all
354
364
  * active MCP clients.
355
365
  */
356
- async getActiveTools(ai: Genkit): Promise<ToolAction[]> {
366
+ async getActiveTools(
367
+ ai: Genkit
368
+ ): Promise<(Multipart extends true ? MultipartToolAction : ToolAction)[]> {
357
369
  await this.ready();
358
- let allTools: ToolAction[] = [];
370
+ let allTools: (Multipart extends true
371
+ ? MultipartToolAction
372
+ : ToolAction)[] = [];
359
373
 
360
374
  for (const serverName in this._clients) {
361
375
  const client = this._clients[serverName];
@@ -510,7 +524,7 @@ export class GenkitMcpHost {
510
524
  /**
511
525
  * Returns an array of all active clients.
512
526
  */
513
- get activeClients(): GenkitMcpClient[] {
527
+ get activeClients(): GenkitMcpClient<Multipart>[] {
514
528
  return Object.values(this._clients).filter((c) => c.isEnabled());
515
529
  }
516
530
 
package/src/index.ts CHANGED
@@ -70,8 +70,10 @@ export interface McpServerOptions {
70
70
  * @param options Configuration for the MCP Client Host, including the definitions of MCP servers to connect to.
71
71
  * @returns A new instance of GenkitMcpHost.
72
72
  */
73
- export function createMcpHost(options: McpHostOptions) {
74
- return new GenkitMcpHost(options);
73
+ export function createMcpHost<M extends boolean = false>(
74
+ options: McpHostOptions<M>
75
+ ) {
76
+ return new GenkitMcpHost<M>(options);
75
77
  }
76
78
 
77
79
  /**
@@ -97,8 +99,11 @@ export function createMcpHost(options: McpHostOptions) {
97
99
  * @param options Configuration for the MCP Client Host, including the definitions of MCP servers to connect to.
98
100
  * @returns A new instance of GenkitMcpHost.
99
101
  */
100
- export function defineMcpHost(ai: Genkit, options: McpHostOptionsWithCache) {
101
- const mcpHost = new GenkitMcpHost(options);
102
+ export function defineMcpHost<M extends boolean = false>(
103
+ ai: Genkit,
104
+ options: McpHostOptionsWithCache<M>
105
+ ) {
106
+ const mcpHost = new GenkitMcpHost<M>(options);
102
107
  const dap = ai.defineDynamicActionProvider(
103
108
  {
104
109
  name: options.name,
@@ -135,8 +140,10 @@ export function defineMcpHost(ai: Genkit, options: McpHostOptionsWithCache) {
135
140
  * to the MCP server and its behavior.
136
141
  * @returns A new instance of GenkitMcpClient.
137
142
  */
138
- export function createMcpClient(options: McpClientOptions) {
139
- return new GenkitMcpClient(options);
143
+ export function createMcpClient<M extends boolean = false>(
144
+ options: McpClientOptions<M>
145
+ ) {
146
+ return new GenkitMcpClient<M>(options);
140
147
  }
141
148
 
142
149
  /**
@@ -165,11 +172,11 @@ export function createMcpClient(options: McpClientOptions) {
165
172
  * to the MCP server and its behavior.
166
173
  * @returns A new instance of GenkitMcpClient.
167
174
  */
168
- export function defineMcpClient(
175
+ export function defineMcpClient<M extends boolean = false>(
169
176
  ai: Genkit,
170
- options: McpClientOptionsWithCache
177
+ options: McpClientOptionsWithCache<M>
171
178
  ) {
172
- const mcpClient = new GenkitMcpClient(options);
179
+ const mcpClient = new GenkitMcpClient<M>(options);
173
180
  const dap = ai.defineDynamicActionProvider(
174
181
  {
175
182
  name: options.name,
package/src/util/tools.ts CHANGED
@@ -19,7 +19,15 @@ import type {
19
19
  CallToolResult,
20
20
  Tool,
21
21
  } from '@modelcontextprotocol/sdk/types.js' with { 'resolution-mode': 'import' };
22
- import { JSONSchema7, z, type Genkit, type ToolAction } from 'genkit';
22
+ import {
23
+ JSONSchema7,
24
+ tool as genkitTool,
25
+ z,
26
+ type Genkit,
27
+ type MultipartToolAction,
28
+ type Part,
29
+ type ToolAction,
30
+ } from 'genkit';
23
31
  import { logger } from 'genkit/logging';
24
32
 
25
33
  const toText = (c: CallToolResult['content']) =>
@@ -27,6 +35,9 @@ const toText = (c: CallToolResult['content']) =>
27
35
 
28
36
  function processResult(result: CallToolResult) {
29
37
  if (result.isError) return { error: toText(result.content) };
38
+ if (result.structuredContent !== undefined) {
39
+ return result.structuredContent;
40
+ }
30
41
  if (result.content.every((c) => c.type === 'text' && !!c.text)) {
31
42
  const text = toText(result.content);
32
43
  if (text.trim().startsWith('{') || text.trim().startsWith('[')) {
@@ -42,6 +53,81 @@ function processResult(result: CallToolResult) {
42
53
  return result;
43
54
  }
44
55
 
56
+ function processMultipartResult(result: CallToolResult) {
57
+ if (result.isError) {
58
+ return {
59
+ output: { error: toText(result.content) },
60
+ metadata: result._meta,
61
+ };
62
+ }
63
+
64
+ const content: Part[] = [];
65
+ let textOutput = '';
66
+
67
+ for (const c of result.content) {
68
+ if (c.type === 'text') {
69
+ if (c.text) {
70
+ textOutput += c.text;
71
+ }
72
+ } else if (c.type === 'image' || c.type === 'audio') {
73
+ if (c.data) {
74
+ content.push({
75
+ media: {
76
+ url: `data:${c.mimeType};base64,${c.data}`,
77
+ contentType: c.mimeType,
78
+ },
79
+ });
80
+ }
81
+ } else if (c.type === 'resource_link') {
82
+ if (c.uri) {
83
+ content.push({
84
+ resource: {
85
+ uri: c.uri,
86
+ },
87
+ });
88
+ }
89
+ } else if (c.type === 'resource') {
90
+ if (c.resource) {
91
+ if ('text' in c.resource && c.resource.text) {
92
+ textOutput +=
93
+ (textOutput ? '\n\n' : '') +
94
+ `Resource (${c.resource.uri}):\n${c.resource.text}`;
95
+ } else if ('blob' in c.resource && c.resource.blob) {
96
+ content.push({
97
+ media: {
98
+ url: `data:${c.resource.mimeType};base64,${c.resource.blob}`,
99
+ contentType: c.resource.mimeType,
100
+ },
101
+ });
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ let output: unknown = result.structuredContent;
108
+
109
+ if (output === undefined && textOutput) {
110
+ if (
111
+ textOutput.trim().startsWith('{') ||
112
+ textOutput.trim().startsWith('[')
113
+ ) {
114
+ try {
115
+ output = JSON.parse(textOutput);
116
+ } catch (e) {
117
+ output = textOutput;
118
+ }
119
+ } else {
120
+ output = textOutput;
121
+ }
122
+ }
123
+
124
+ return {
125
+ ...(output !== undefined ? { output } : {}),
126
+ ...(content.length > 0 ? { content } : {}),
127
+ metadata: result._meta,
128
+ };
129
+ }
130
+
45
131
  /**
46
132
  * Registers a single MCP tool as a Genkit tool.
47
133
  * It defines a new Genkit tool action that, when called, will
@@ -57,32 +143,69 @@ function registerTool(
57
143
  ai: Genkit,
58
144
  client: Client,
59
145
  tool: Tool,
60
- params: { serverName: string; name: string; rawToolResponses?: boolean }
146
+ params: {
147
+ serverName: string;
148
+ name: string;
149
+ rawToolResponses?: boolean;
150
+ multipart?: boolean;
151
+ }
61
152
  ) {
62
153
  logger.debug(
63
- `[MCP] Registering tool '${params.name}/${tool.name}'' from server '${params.serverName}'`
64
- );
65
- ai.defineTool(
66
- {
67
- name: `${params.serverName}/${tool.name}`,
68
- description: tool.description || '',
69
- inputJsonSchema: tool.inputSchema as JSONSchema7,
70
- outputSchema: z.any(),
71
- metadata: { mcp: { _meta: tool._meta || {} } },
72
- },
73
- async (args) => {
74
- logger.debug(
75
- `[MCP] Calling MCP tool '${params.serverName}/${tool.name}' with arguments`,
76
- JSON.stringify(args)
77
- );
78
- const result = await client.callTool({
79
- name: tool.name,
80
- arguments: args,
81
- });
82
- if (params.rawToolResponses) return result;
83
- return processResult(result as CallToolResult);
84
- }
154
+ `[MCP] Registering tool '${params.name}/${tool.name}' from server '${params.serverName}'`
85
155
  );
156
+ if (params.multipart && params.rawToolResponses) {
157
+ logger.warn(
158
+ `[MCP] Tool '${params.serverName}/${tool.name}' is configured with both multipart and rawToolResponses. Genkit will return the raw MCP CallToolResult in the output field, and media parts will not be natively parsed.`
159
+ );
160
+ }
161
+ if (params.multipart) {
162
+ ai.defineTool(
163
+ {
164
+ name: `${params.serverName}/${tool.name}`,
165
+ description: tool.description || '',
166
+ inputJsonSchema: tool.inputSchema as JSONSchema7,
167
+ outputSchema: z.any(),
168
+ metadata: { mcp: { _meta: tool._meta || {} } },
169
+ multipart: true as const,
170
+ },
171
+ async (args, { context }) => {
172
+ logger.debug(
173
+ `[MCP] Calling MCP tool '${params.serverName}/${tool.name}' with arguments`,
174
+ JSON.stringify(args)
175
+ );
176
+ const result = await client.callTool({
177
+ name: tool.name,
178
+ arguments: args,
179
+ _meta: context?.mcp?._meta,
180
+ });
181
+ if (params.rawToolResponses) return { output: result };
182
+ return processMultipartResult(result as CallToolResult);
183
+ }
184
+ );
185
+ } else {
186
+ ai.defineTool(
187
+ {
188
+ name: `${params.serverName}/${tool.name}`,
189
+ description: tool.description || '',
190
+ inputJsonSchema: tool.inputSchema as JSONSchema7,
191
+ outputSchema: z.any(),
192
+ metadata: { mcp: { _meta: tool._meta || {} } },
193
+ },
194
+ async (args, { context }) => {
195
+ logger.debug(
196
+ `[MCP] Calling MCP tool '${params.serverName}/${tool.name}' with arguments`,
197
+ JSON.stringify(args)
198
+ );
199
+ const result = await client.callTool({
200
+ name: tool.name,
201
+ arguments: args,
202
+ _meta: context?.mcp?._meta,
203
+ });
204
+ if (params.rawToolResponses) return result;
205
+ return processResult(result as CallToolResult);
206
+ }
207
+ );
208
+ }
86
209
  }
87
210
 
88
211
  /**
@@ -96,13 +219,48 @@ function registerTool(
96
219
  * @param params Configuration parameters including namespacing and raw response flag.
97
220
  * @returns A Genkit `ToolAction` representing the MCP tool.
98
221
  */
99
- function createDynamicTool(
222
+ function createDynamicTool<Multipart extends boolean = false>(
100
223
  ai: Genkit,
101
224
  client: Client,
102
225
  tool: Tool,
103
- params: { serverName: string; name: string; rawToolResponses?: boolean }
104
- ): ToolAction {
105
- return ai.dynamicTool(
226
+ params: {
227
+ serverName: string;
228
+ name: string;
229
+ rawToolResponses?: boolean;
230
+ multipart?: Multipart;
231
+ }
232
+ ): Multipart extends true ? MultipartToolAction : ToolAction {
233
+ if (params.multipart && params.rawToolResponses) {
234
+ logger.warn(
235
+ `[MCP] Tool '${params.serverName}/${tool.name}' is configured with both multipart and rawToolResponses. Genkit will return the raw MCP CallToolResult in the output field, and media parts will not be natively parsed.`
236
+ );
237
+ }
238
+ if (params.multipart) {
239
+ return genkitTool(
240
+ {
241
+ name: `${params.serverName}/${tool.name}`,
242
+ description: tool.description || '',
243
+ inputJsonSchema: tool.inputSchema as JSONSchema7,
244
+ outputSchema: z.any(),
245
+ metadata: { mcp: { _meta: tool._meta || {} } },
246
+ multipart: true as const,
247
+ },
248
+ async (args, { context }) => {
249
+ logger.debug(
250
+ `[MCP] calling tool '${params.serverName}/${tool.name}' in host '${params.name}'`
251
+ );
252
+ const result = await client.callTool({
253
+ name: tool.name,
254
+ arguments: args,
255
+ _meta: context?.mcp?._meta,
256
+ });
257
+ if (params.rawToolResponses) return { output: result };
258
+ return processMultipartResult(result as CallToolResult);
259
+ }
260
+ ) as unknown as Multipart extends true ? MultipartToolAction : ToolAction;
261
+ }
262
+
263
+ return genkitTool(
106
264
  {
107
265
  name: `${params.serverName}/${tool.name}`,
108
266
  description: tool.description || '',
@@ -119,10 +277,10 @@ function createDynamicTool(
119
277
  arguments: args,
120
278
  _meta: context?.mcp?._meta,
121
279
  });
122
- if (params.rawToolResponses) return result;
280
+ if (params.rawToolResponses) return result as CallToolResult;
123
281
  return processResult(result as CallToolResult);
124
282
  }
125
- );
283
+ ) as unknown as Multipart extends true ? MultipartToolAction : ToolAction;
126
284
  }
127
285
 
128
286
  /**
@@ -131,7 +289,12 @@ function createDynamicTool(
131
289
  export async function registerAllTools(
132
290
  ai: Genkit,
133
291
  client: Client,
134
- params: { name: string; serverName: string; rawToolResponses?: boolean }
292
+ params: {
293
+ name: string;
294
+ serverName: string;
295
+ rawToolResponses?: boolean;
296
+ multipart?: boolean;
297
+ }
135
298
  ): Promise<void> {
136
299
  let cursor: string | undefined;
137
300
  while (true) {
@@ -145,13 +308,19 @@ export async function registerAllTools(
145
308
  /**
146
309
  * Lookup all tools available in the server and fetches as a Genkit dynamic tool.
147
310
  */
148
- export async function fetchDynamicTools(
311
+ export async function fetchDynamicTools<Multipart extends boolean = false>(
149
312
  ai: Genkit,
150
313
  client: Client,
151
- params: { name: string; serverName: string; rawToolResponses?: boolean }
152
- ): Promise<ToolAction[]> {
314
+ params: {
315
+ name: string;
316
+ serverName: string;
317
+ rawToolResponses?: boolean;
318
+ multipart?: Multipart;
319
+ }
320
+ ): Promise<(Multipart extends true ? MultipartToolAction : ToolAction)[]> {
153
321
  let cursor: string | undefined;
154
- let allTools: ToolAction[] = [];
322
+ let allTools: (Multipart extends true ? MultipartToolAction : ToolAction)[] =
323
+ [];
155
324
  while (true) {
156
325
  const { nextCursor, tools } = await client.listTools({ cursor });
157
326
  allTools.push(
@@ -251,12 +251,162 @@ describe('createMcpHost', () => {
251
251
  ],
252
252
  };
253
253
 
254
- const tool = (await clientHost.getActiveTools(ai))[0];
254
+ const tool: ToolAction = (await clientHost.getActiveTools(ai))[0];
255
255
  const response = await tool({
256
256
  foo: 'bar',
257
257
  });
258
258
  assert.deepStrictEqual(response, 'yep {"foo":"bar"}');
259
259
  });
260
+
261
+ it('should call the multipart tool', async () => {
262
+ const multipartHost = createMcpHost({
263
+ name: 'test-multipart-host',
264
+ multipart: true,
265
+ mcpServers: {
266
+ 'test-server': {
267
+ transport: fakeTransport,
268
+ },
269
+ },
270
+ });
271
+ await multipartHost.ready();
272
+
273
+ fakeTransport.callToolResult = {
274
+ content: [
275
+ {
276
+ type: 'text',
277
+ text: 'yep {"foo":"bar"}',
278
+ },
279
+ {
280
+ type: 'image',
281
+ data: 'base64data',
282
+ mimeType: 'image/png',
283
+ },
284
+ {
285
+ type: 'resource',
286
+ resource: {
287
+ uri: 'file:///foo.txt',
288
+ text: 'hello resource',
289
+ },
290
+ },
291
+ {
292
+ type: 'resource',
293
+ resource: {
294
+ uri: 'file:///blob.bin',
295
+ blob: 'base64blob',
296
+ mimeType: 'application/octet-stream',
297
+ },
298
+ },
299
+ ],
300
+ _meta: {
301
+ someData: true,
302
+ },
303
+ };
304
+
305
+ const tools = await multipartHost.getActiveTools(ai);
306
+ const tool = tools[0];
307
+ const response = await tool(
308
+ { foo: 'bar' },
309
+ { context: { mcp: { _meta: { soMeta: true } } } }
310
+ );
311
+
312
+ assert.deepStrictEqual(response, {
313
+ output:
314
+ 'yep {"foo":"bar"}\n\nResource (file:///foo.txt):\nhello resource{"soMeta":true}',
315
+ content: [
316
+ {
317
+ media: {
318
+ url: 'data:image/png;base64,base64data',
319
+ contentType: 'image/png',
320
+ },
321
+ },
322
+ {
323
+ media: {
324
+ url: 'data:application/octet-stream;base64,base64blob',
325
+ contentType: 'application/octet-stream',
326
+ },
327
+ },
328
+ ],
329
+ metadata: {
330
+ someData: true,
331
+ },
332
+ });
333
+ });
334
+
335
+ it('should call the multipart tool and handle errors', async () => {
336
+ const multipartHost = createMcpHost({
337
+ name: 'test-multipart-host',
338
+ multipart: true,
339
+ mcpServers: {
340
+ 'test-server': {
341
+ transport: fakeTransport,
342
+ },
343
+ },
344
+ });
345
+ await multipartHost.ready();
346
+
347
+ fakeTransport.callToolResult = {
348
+ isError: true,
349
+ content: [
350
+ {
351
+ type: 'text',
352
+ text: 'Simulated tool failure',
353
+ },
354
+ ],
355
+ _meta: {
356
+ errorCode: 500,
357
+ },
358
+ };
359
+
360
+ const tools = await multipartHost.getActiveTools(ai);
361
+ const tool = tools[0];
362
+ const response = await tool({ foo: 'bar' });
363
+
364
+ assert.deepStrictEqual(response, {
365
+ output: { error: 'Simulated tool failure' },
366
+ metadata: {
367
+ errorCode: 500,
368
+ },
369
+ });
370
+ });
371
+
372
+ it('should return raw tool response when rawToolResponses is true alongside multipart', async () => {
373
+ const multipartHost = createMcpHost({
374
+ name: 'test-multipart-host',
375
+ multipart: true,
376
+ rawToolResponses: true,
377
+ mcpServers: {
378
+ 'test-server': {
379
+ transport: fakeTransport,
380
+ },
381
+ },
382
+ });
383
+ await multipartHost.ready();
384
+
385
+ fakeTransport.callToolResult = {
386
+ content: [
387
+ {
388
+ type: 'text',
389
+ text: 'yep {"foo":"bar"}',
390
+ },
391
+ ],
392
+ };
393
+
394
+ const tools = await multipartHost.getActiveTools(ai);
395
+ const tool = tools[0];
396
+ const response = await tool({ foo: 'bar' });
397
+
398
+ // Genkit output schema for multipart expects `{ output: ... }` when returning raw responses
399
+ assert.deepStrictEqual(response, {
400
+ output: {
401
+ content: [
402
+ {
403
+ type: 'text',
404
+ text: 'yep {"foo":"bar"}',
405
+ },
406
+ ],
407
+ },
408
+ });
409
+ });
260
410
  });
261
411
 
262
412
  describe('prompts', () => {