Skip to content

feat(js/mcp): Adds getActivePrompts() to host. #3071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions js/plugins/mcp/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
ToolAction,
} from 'genkit';
import { logger } from 'genkit/logging';
import { fetchDynamicTools, getExecutablePrompt, transportFrom } from '../util';
import {
fetchAllPrompts,
fetchDynamicTools,
getExecutablePrompt,
transportFrom,
} from '../util';
export type { SSEClientTransportOptions, StdioServerParameters, Transport };

interface McpServerRef {
Expand All @@ -39,12 +44,6 @@ export interface McpServerControls {
/** when true, the server will be stopped and its registered components will
* not appear in lists/plugins/etc */
disabled?: boolean;
/**
* If true, tool responses from the MCP server will be returned in their raw
* MCP format. Otherwise (default), they are processed and potentially
* simplified for better compatibility with Genkit's typical data structures.
*/
rawToolResponses?: boolean;
}

export type McpStdioServerConfig = StdioServerParameters;
Expand Down Expand Up @@ -76,14 +75,25 @@ export type McpServerConfig = (
* Configuration options for an individual `GenkitMcpClient` instance.
* This defines how the client connects to a single MCP server and how it behaves.
*/
export type McpClientOptions = McpServerConfig & {
/** Name for this server configuration */
export type McpClientOptions = {
/** Client name to advertise to the server. */
name: string;
/** Name for the server, defaults to the server's advertised name. */
serverName?: string;

/**
* An optional version number for this client. This is primarily for logging
* and identification purposes. Defaults to '1.0.0'.
*/
version?: string;
/**
* If true, tool responses from the MCP server will be returned in their raw
* MCP format. Otherwise (default), they are processed and potentially
* simplified for better compatibility with Genkit's typical data structures.
*/
rawToolResponses?: boolean;
/** The server configuration to connect. */
mcpServer: McpServerConfig;
};

/**
Expand All @@ -98,6 +108,7 @@ export class GenkitMcpClient {
_server?: McpServerRef;

readonly name: string;
readonly suppliedServerName?: string;
private version: string;
private serverConfig: McpServerConfig;
private rawToolResponses?: boolean;
Expand All @@ -111,14 +122,23 @@ export class GenkitMcpClient {

constructor(options: McpClientOptions) {
this.name = options.name;
this.suppliedServerName = options.serverName;
this.version = options.version || '1.0.0';
this.serverConfig = { ...options };
this.serverConfig = options.mcpServer;
this.rawToolResponses = !!options.rawToolResponses;
this.disabled = !!options.disabled;
this.disabled = !!options.mcpServer.disabled;

this._initializeConnection();
}

get serverName(): string {
return (
this.suppliedServerName ??
this._server?.client.getServerVersion()?.name ??
'unknown-server'
);
}

/**
* Sets up a connection based on a provided map of server configurations.
* - Reconnects existing servers if their configuration appears to have
Expand Down Expand Up @@ -266,12 +286,12 @@ export class GenkitMcpClient {
let tools: ToolAction[] = [];

if (this._server) {
const capabilities = await this._server.client.getServerCapabilities();
const capabilities = this._server.client.getServerCapabilities();
if (capabilities?.tools)
tools.push(
...(await fetchDynamicTools(ai, this._server.client, {
rawToolResponses: this.rawToolResponses,
serverName: this.name,
serverName: this.serverName,
name: this.name,
}))
);
Expand All @@ -280,6 +300,21 @@ export class GenkitMcpClient {
return tools;
}

async getActivePrompts(
ai: Genkit,
options?: PromptGenerateOptions
): Promise<ExecutablePrompt[]> {
if (this._server?.client.getServerCapabilities()?.prompts) {
return fetchAllPrompts(this._server.client, {
ai,
serverName: this.serverName,
name: this.name,
options,
});
}
return [];
}

/**
* Get the specified prompt as an `ExecutablePrompt` available through this
* client. If no such prompt is found, return undefined.
Expand Down
26 changes: 21 additions & 5 deletions js/plugins/mcp/src/client/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@ import { GenkitMcpClient, McpServerConfig } from './client.js';

export interface McpHostOptions {
/**
* An optional name for this MCP host. This name is primarily for
* logging and identification purposes within Genkit.
* Defaults to 'genkitx-mcp'.
* An optional client name for this MCP host. This name is advertised to MCP Servers
* as the connecting client name. Defaults to 'genkit-mcp'.
*/
name?: string;
/**
Expand Down Expand Up @@ -72,7 +71,7 @@ export class GenkitMcpHost {
private _ready = false;

constructor(options: McpHostOptions) {
this.name = options.name || 'genkitx-mcp';
this.name = options.name || 'genkit-mcp';

if (options.mcpServers) {
this.updateServers(options.mcpServers);
Expand Down Expand Up @@ -119,7 +118,11 @@ export class GenkitMcpHost {
`[MCP Host] Connecting to MCP server '${serverName}' in host '${this.name}'.`
);
try {
const client = new GenkitMcpClient({ ...config, name: serverName });
const client = new GenkitMcpClient({
name: this.name,
serverName: serverName,
mcpServer: config,
});
this._clients[serverName] = client;
} catch (e) {
this.setError(serverName, {
Expand Down Expand Up @@ -323,6 +326,19 @@ export class GenkitMcpHost {
return allTools;
}

async getActivePrompts(ai: Genkit): Promise<ExecutablePrompt[]> {
await this.ready();
let allPrompts: ExecutablePrompt[] = [];

for (const serverName in this._clients) {
const client = this._clients[serverName];
if (client.isEnabled() && !this.hasError(serverName)) {
allPrompts.push(...(await client.getActivePrompts(ai)));
}
}
return allPrompts;
}

/**
* Get the specified prompt as an `ExecutablePrompt` available through the
* specified server. If no such prompt is found, return undefined.
Expand Down
25 changes: 25 additions & 0 deletions js/plugins/mcp/src/util/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,28 @@ export async function getExecutablePrompt(
}
return undefined;
}

export async function fetchAllPrompts(
client: Client,
params: {
name: string;
serverName: string;
ai: Genkit;
options?: PromptGenerateOptions;
}
): Promise<ExecutablePrompt[]> {
let cursor: string | undefined;
const out: ExecutablePrompt[] = [];

while (true) {
const { nextCursor, prompts } = await client.listPrompts({ cursor });
for (const p of prompts) {
out.push(
createExecutablePrompt(client, p, { ...params, promptName: p.name })
);
}
cursor = nextCursor;
if (!cursor) break;
}
return out;
}
6 changes: 3 additions & 3 deletions js/plugins/mcp/src/util/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function listResourcesTool(
params: { asDynamicTool?: boolean }
): ToolAction {
const actionMetadata = {
name: `mcp/list_resources`,
name: `${host.name}/list_resources`,
description: `list all available resources`,
inputSchema: z
.object({
Expand Down Expand Up @@ -87,15 +87,15 @@ function readResourceTool(
params: { asDynamicTool?: boolean }
) {
const actionMetadata = {
name: `mcp/read_resource`,
name: `${host.name}/read_resource`,
description: `this tool can read resources`,
inputSchema: z.object({
server: z.string(),
uri: z.string().describe('the URI of the resource to retrieve'),
}),
};
const fn = async ({ server, uri }) => {
const client = host.activeClients.find((c) => c.name === server);
const client = host.activeClients.find((c) => c.serverName === server);
if (!client) {
throw new GenkitError({
status: 'NOT_FOUND',
Expand Down
2 changes: 1 addition & 1 deletion js/plugins/mcp/src/util/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function createDynamicTool(
);
return ai.dynamicTool(
{
name: `${params.name}/${tool.name}`,
name: `${params.serverName}/${tool.name}`,
description: tool.description || '',
inputJsonSchema: tool.inputSchema as JSONSchema7,
outputSchema: z.any(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,7 @@ describe('createMcpHost', () => {
},
});

fakeTransport.prompts.push({
name: 'testPrompt',
});
// Note: fakeTransport.prompts.push({ name: 'testPrompt' }); is moved to specific tests
fakeTransport.getPromptResult = {
messages: [
{
Expand All @@ -230,7 +228,56 @@ describe('createMcpHost', () => {
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',
});
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.strictEqual(activePrompts.length, 2);

// Disable the first server
await clientHost.disable('test-server');
activePrompts = await clientHost.getActivePrompts(ai);
assert.strictEqual(activePrompts.length, 1);

// Enable the first server again
await clientHost.enable('test-server');
activePrompts = await clientHost.getActivePrompts(ai);
assert.strictEqual(activePrompts.length, 2);
});

it('should execute prompt', async () => {
fakeTransport.prompts.push({
name: 'testPrompt',
});
const prompt = await clientHost.getPrompt(
ai,
'test-server',
Expand All @@ -249,6 +296,9 @@ describe('createMcpHost', () => {
});

it('should render prompt', async () => {
fakeTransport.prompts.push({
name: 'testPrompt',
});
const prompt = await clientHost.getPrompt(
ai,
'test-server',
Expand All @@ -266,6 +316,9 @@ describe('createMcpHost', () => {
});

it('should stream prompt', async () => {
fakeTransport.prompts.push({
name: 'testPrompt',
});
const prompt = await clientHost.getPrompt(
ai,
'test-server',
Expand Down Expand Up @@ -343,20 +396,24 @@ describe('createMcpHost', () => {
(await clientHost.getActiveTools(ai, { resourceTools: true })).map(
(t) => t.__action.name
),
['test-server/testTool', 'mcp/list_resources', 'mcp/read_resource']
[
'test-server/testTool',
'test-mcp-host/list_resources',
'test-mcp-host/read_resource',
]
);
});

it('should list resources and templates', async () => {
const listResourcesTool = (
await clientHost.getActiveTools(ai, { resourceTools: true })
).find((t) => t.__action.name === 'mcp/list_resources');
).find((t) => t.__action.name === 'test-mcp-host/list_resources');
assert.ok(listResourcesTool);

const response = await listResourcesTool({});
assert.deepStrictEqual(response, {
servers: {
'test-server': {
'test-mcp-host': {
resources: [
{
description: 'test resource',
Expand Down Expand Up @@ -388,7 +445,7 @@ describe('createMcpHost', () => {

const readResourceTool = (
await clientHost.getActiveTools(ai, { resourceTools: true })
).find((t) => t.__action.name === 'mcp/read_resource');
).find((t) => t.__action.name === 'test-mcp-host/read_resource');
assert.ok(readResourceTool);

const response = await readResourceTool({
Expand Down