/** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as assert from 'assert'; import { Genkit, genkit, ToolAction } from 'genkit'; import { logger } from 'genkit/logging'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { createMcpHost, GenkitMcpHost } from '../src/index.js'; import { defineEchoModel, FakeTransport } from './fakes.js'; logger.setLogLevel('debug'); describe('createMcpHost', () => { let ai: Genkit; beforeEach(async () => { ai = genkit({}); defineEchoModel(ai); }); describe('host', () => { let fakeTransport1: FakeTransport; let fakeTransport2: FakeTransport; let clientHost: GenkitMcpHost; beforeEach(() => { clientHost = createMcpHost({ name: 'test-mcp-host', }); fakeTransport1 = new FakeTransport(); fakeTransport1.tools.push({ name: 'testTool1', inputSchema: { type: 'object', properties: { foo: { type: 'string', }, }, required: ['foo'], additionalProperties: true, $schema: 'http://json-schema.org/draft-07/schema#', }, description: 'test tool 1', }); fakeTransport2 = new FakeTransport(); fakeTransport2.tools.push({ name: 'testTool2', inputSchema: { type: 'object', properties: { foo: { type: 'string', }, }, required: ['foo'], additionalProperties: true, $schema: 'http://json-schema.org/draft-07/schema#', }, description: 'test tool 2', }); }); afterEach(async () => { await clientHost?.close(); }); it('should dynamically connect clients', async () => { // no server connected, no tools assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), [] ); // connect fakeTransport1 await clientHost.connect('test-mcp-host1', { transport: fakeTransport1, }); assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-mcp-host1/testTool1'] ); // connect fakeTransport2 await clientHost.connect('test-mcp-host2', { transport: fakeTransport2, }); assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-mcp-host1/testTool1', 'test-mcp-host2/testTool2'] ); // disable await clientHost.disable('test-mcp-host1'); assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-mcp-host2/testTool2'] ); // reconnect await clientHost.enable('test-mcp-host1'); assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-mcp-host1/testTool1', 'test-mcp-host2/testTool2'] ); // disconnect await clientHost.disconnect('test-mcp-host1'); assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-mcp-host2/testTool2'] ); }); it('updated roots', async () => { // no server connected, no tools assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), [] ); // connect fakeTransport1 await clientHost.connect('test-mcp-host1', { transport: fakeTransport1, roots: [ { uri: `file:///foo`, name: 'foo', }, ], }); // MCP communicates roots async... await new Promise((r) => setTimeout(r, 10)); assert.deepStrictEqual(fakeTransport1.roots, [ { name: 'foo', uri: 'file:///foo', }, ]); await clientHost.getClient('test-mcp-host1').updateRoots([ { uri: `file:///bar`, name: 'bar', }, ]); // MCP communicates roots async... await new Promise((r) => setTimeout(r, 10)); assert.deepStrictEqual(fakeTransport1.roots, [ { name: 'bar', uri: 'file:///bar', }, ]); }); }); describe('tools', () => { let fakeTransport: FakeTransport; let clientHost: GenkitMcpHost; beforeEach(() => { fakeTransport = new FakeTransport(); clientHost = createMcpHost({ name: 'test-mcp-host', mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); fakeTransport.tools.push({ name: 'testTool', inputSchema: { type: 'object', properties: { foo: { type: 'string', }, }, required: ['foo'], additionalProperties: true, $schema: 'http://json-schema.org/draft-07/schema#', }, description: 'test tool', }); }); afterEach(() => { clientHost?.close(); }); it('should list tools', async () => { assert.deepStrictEqual( (await clientHost.getActiveTools(ai)).map((t) => t.__action.name), ['test-server/testTool'] ); }); it('should call the tool', async () => { fakeTransport.callToolResult = { content: [ { type: 'text', text: 'yep {"foo":"bar"}', }, ], }; const tool: ToolAction = (await clientHost.getActiveTools(ai))[0]; const response = await tool( { foo: 'bar', }, { context: { mcp: { _meta: { soMeta: true } } } } ); assert.deepStrictEqual(response, 'yep {"foo":"bar"}{"soMeta":true}'); }); it('should call the tool with _meta', async () => { fakeTransport.callToolResult = { content: [ { type: 'text', text: 'yep {"foo":"bar"}', }, ], }; const tool: ToolAction = (await clientHost.getActiveTools(ai))[0]; const response = await tool({ foo: 'bar', }); assert.deepStrictEqual(response, 'yep {"foo":"bar"}'); }); it('should call the multipart tool', async () => { const multipartHost = createMcpHost({ name: 'test-multipart-host', multipart: true, mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); await multipartHost.ready(); fakeTransport.callToolResult = { content: [ { type: 'text', text: 'yep {"foo":"bar"}', }, { type: 'image', data: 'base64data', mimeType: 'image/png', }, { type: 'resource', resource: { uri: 'file:///foo.txt', text: 'hello resource', }, }, { type: 'resource', resource: { uri: 'file:///blob.bin', blob: 'base64blob', mimeType: 'application/octet-stream', }, }, ], _meta: { someData: true, }, }; const tools = await multipartHost.getActiveTools(ai); const tool = tools[0]; const response = await tool( { foo: 'bar' }, { context: { mcp: { _meta: { soMeta: true } } } } ); assert.deepStrictEqual(response, { output: 'yep {"foo":"bar"}\n\nResource (file:///foo.txt):\nhello resource{"soMeta":true}', content: [ { media: { url: 'data:image/png;base64,base64data', contentType: 'image/png', }, }, { media: { url: 'data:application/octet-stream;base64,base64blob', contentType: 'application/octet-stream', }, }, ], metadata: { someData: true, }, }); }); it('should call the multipart tool and handle errors', async () => { const multipartHost = createMcpHost({ name: 'test-multipart-host', multipart: true, mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); await multipartHost.ready(); fakeTransport.callToolResult = { isError: true, content: [ { type: 'text', text: 'Simulated tool failure', }, ], _meta: { errorCode: 500, }, }; const tools = await multipartHost.getActiveTools(ai); const tool = tools[0]; const response = await tool({ foo: 'bar' }); assert.deepStrictEqual(response, { output: { error: 'Simulated tool failure' }, metadata: { errorCode: 500, }, }); }); it('should return raw tool response when rawToolResponses is true alongside multipart', async () => { const multipartHost = createMcpHost({ name: 'test-multipart-host', multipart: true, rawToolResponses: true, mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); await multipartHost.ready(); fakeTransport.callToolResult = { content: [ { type: 'text', text: 'yep {"foo":"bar"}', }, ], }; const tools = await multipartHost.getActiveTools(ai); const tool = tools[0]; const response = await tool({ foo: 'bar' }); // Genkit output schema for multipart expects `{ output: ... }` when returning raw responses assert.deepStrictEqual(response, { output: { content: [ { type: 'text', text: 'yep {"foo":"bar"}', }, ], }, }); }); }); describe('prompts', () => { let fakeTransport: FakeTransport; let clientHost: GenkitMcpHost; beforeEach(() => { fakeTransport = new FakeTransport(); clientHost = createMcpHost({ name: 'test-mcp-host', mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); // Note: fakeTransport.prompts.push({ name: 'testPrompt' }); is moved to specific tests fakeTransport.getPromptResult = { messages: [ { role: 'user', content: { type: 'text', text: 'prompt says: hello', }, }, ], }; }); afterEach(() => { clientHost?.close(); }); it('should list active prompts', async () => { // Initially no prompts assert.deepStrictEqual(await clientHost.getActivePrompts(ai), []); // Add a prompt to the first transport fakeTransport.prompts.push({ name: 'testPrompt1', arguments: [ { name: 'foo', description: 'foo arg', required: false, }, ], description: 'descr', _meta: { foo: true }, }); let activePrompts = await clientHost.getActivePrompts(ai); assert.strictEqual(activePrompts.length, 1); assert.deepStrictEqual(await activePrompts[0].render(), { messages: [ { role: 'user', content: [ { text: 'prompt says: hello', }, ], }, ], }); // Add a second transport with another prompt const fakeTransport2 = new FakeTransport(); fakeTransport2.prompts.push({ name: 'testPrompt2', }); await clientHost.connect('test-server-2', { transport: fakeTransport2, }); activePrompts = await clientHost.getActivePrompts(ai); assert.deepStrictEqual(activePrompts[0].ref.metadata, { arguments: [ { description: 'foo arg', name: 'foo', required: false, }, ], description: 'descr', mcp: { _meta: { foo: true } }, }); assert.deepStrictEqual( activePrompts.map((p) => p.ref.name), ['testPrompt1', 'testPrompt2'] ); // Disable the first server await clientHost.disable('test-server'); activePrompts = await clientHost.getActivePrompts(ai); assert.deepStrictEqual( activePrompts.map((p) => p.ref.name), ['testPrompt2'] ); // Enable the first server again await clientHost.enable('test-server'); activePrompts = await clientHost.getActivePrompts(ai); assert.deepStrictEqual( activePrompts.map((p) => p.ref.name), ['testPrompt1', 'testPrompt2'] ); }); it('should execute prompt', async () => { fakeTransport.prompts.push({ name: 'testPrompt', }); const prompt = await clientHost.getPrompt( ai, 'test-server', 'testPrompt', { model: 'echoModel', config: { temperature: 11 } } ); assert.ok(prompt); const { text } = await prompt({ input: 'hello', }); assert.strictEqual( text, 'Echo: prompt says: hello; config: {"temperature":11}' ); }); it('should render prompt', async () => { fakeTransport.prompts.push({ name: 'testPrompt', }); const prompt = await clientHost.getPrompt( ai, 'test-server', 'testPrompt', { model: 'echoModel', config: { temperature: 11 } } ); assert.ok(prompt); const request = await prompt.render({ input: 'hello', }); assert.deepStrictEqual(request.messages, [ { role: 'user', content: [{ text: 'prompt says: hello' }] }, ]); }); it('should render prompt with _meta', async () => { fakeTransport.prompts.push({ name: 'testPrompt', }); const prompt = await clientHost.getPrompt( ai, 'test-server', 'testPrompt', { model: 'echoModel', config: { temperature: 11 } } ); assert.ok(prompt); const request = await prompt.render( { input: 'hello', }, { context: { mcp: { _meta: { soMeta: true } } } } ); assert.deepStrictEqual(request.messages, [ { role: 'user', content: [{ text: 'prompt says: hello' }] }, { role: 'model', content: [{ text: '{"soMeta":true}' }] }, ]); }); it('should stream prompt', async () => { fakeTransport.prompts.push({ name: 'testPrompt', }); const prompt = await clientHost.getPrompt( ai, 'test-server', 'testPrompt', { model: 'echoModel', config: { temperature: 11 } } ); assert.ok(prompt); const { stream, response } = prompt.stream({ input: 'hello', }); const chunks = [] as string[]; for await (const chunk of stream) { chunks.push(chunk.text); } assert.deepStrictEqual(chunks, ['3', '2', '1']); assert.strictEqual( (await response).text, 'Echo: prompt says: hello; config: {"temperature":11}' ); }); }); describe('resources', () => { let fakeTransport: FakeTransport; let clientHost: GenkitMcpHost; beforeEach(() => { fakeTransport = new FakeTransport(); clientHost = createMcpHost({ name: 'test-mcp-host', mcpServers: { 'test-server': { transport: fakeTransport, }, }, }); }); afterEach(() => { clientHost?.close(); }); it('should list active resources', async () => { // Initially no prompts assert.deepStrictEqual(await clientHost.getActiveResources(ai), []); // Add a prompt to the first transport fakeTransport.resources.push({ name: 'testResource1', uri: 'test://resource/1', description: 'test resource 1', _meta: { foo: true }, }); fakeTransport.resourceTemplates.push({ name: 'testResourceTmpl', uriTemplate: 'test://resource/{id}', description: 'test resource template', _meta: { foo: true }, }); let activeResources = await clientHost.getActiveResources(ai); assert.strictEqual(activeResources.length, 2); // Add a second transport with another prompt const fakeTransport2 = new FakeTransport(); fakeTransport2.resources.push({ name: 'testResource2', uri: 'test://resource/2', description: 'test resource 2', _meta: { foo: true }, }); await clientHost.connect('test-server-2', { transport: fakeTransport2, }); activeResources = await clientHost.getActiveResources(ai); assert.deepStrictEqual(activeResources[0].__action.metadata, { type: 'resource', dynamic: true, resource: { template: undefined, uri: 'test://resource/1', }, mcp: { _meta: { foo: true } }, }); assert.deepStrictEqual( activeResources.map((p) => p.__action.name), [ 'test-server/testResource1', 'test-server/testResourceTmpl', 'test-server-2/testResource2', ] ); // Disable the first server await clientHost.disable('test-server'); activeResources = await clientHost.getActiveResources(ai); assert.deepStrictEqual( activeResources.map((p) => p.__action.name), ['test-server-2/testResource2'] ); // Enable the first server again await clientHost.enable('test-server'); activeResources = await clientHost.getActiveResources(ai); assert.deepStrictEqual( activeResources.map((p) => p.__action.name), [ 'test-server/testResource1', 'test-server/testResourceTmpl', 'test-server-2/testResource2', ] ); }); it('should render resource', async () => { fakeTransport.resources.push({ name: 'testResource1', uri: 'test://resource/1', description: 'test resource 1', _meta: { foo: true }, }); fakeTransport.readResourceResult = { contents: [ { uri: 'test://resource/1', text: 'text resource', }, { uri: 'test://resource/1', blob: 'UmVzb3VyY2UgMjogVGhpcyBpcyBhIGJhc2U2NCBibG9i', mimeType: 'application/png', }, ], }; const prompt = (await clientHost.getActiveResources(ai))[0]; assert.ok(prompt); const response = await prompt.attach(ai.registry)({ uri: 'test://resource/1', }); assert.deepStrictEqual(response, { content: [ { text: 'text resource', metadata: { resource: { uri: 'test://resource/1', }, }, }, { media: { contentType: 'application/png', url: 'data:application/png;base64,UmVzb3VyY2UgMjogVGhpcyBpcyBhIGJhc2U2NCBibG9i', }, metadata: { resource: { uri: 'test://resource/1', }, }, }, ], }); }); }); });