Skip to content

Commit

Permalink
feat(tools): add Model Context Protocol tool
Browse files Browse the repository at this point in the history
Signed-off-by: Tomas Pilar <[email protected]>
  • Loading branch information
pilartomas committed Jan 3, 2025
1 parent fb2153c commit 9f04e7f
Show file tree
Hide file tree
Showing 6 changed files with 351 additions and 10 deletions.
55 changes: 55 additions & 0 deletions examples/tools/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright 2025 IBM Corp.
*
* 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 { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { MCPResourceTool } from "bee-agent-framework/tools/mcp/mcpResource";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { OllamaChatLLM } from "bee-agent-framework/adapters/ollama/chat";
import { BeeAgent } from "bee-agent-framework/agents/bee/agent";
import { UnconstrainedMemory } from "bee-agent-framework/memory/unconstrainedMemory";

const client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{
capabilities: {},
},
);
await client.connect(
new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
}),
);

const agent = new BeeAgent({
llm: new OllamaChatLLM(),
memory: new UnconstrainedMemory(),
tools: [
new MCPResourceTool({
client,
}),
],
});
await agent.run({ prompt: "Read contents the first resource" }).observe((emitter) => {
emitter.on("update", async ({ data, update, meta }) => {
console.log(`Agent (${update.key}) 🤖 : `, update.value);
});
});

await client.close();
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"@ai-zen/node-fetch-event-source": "^2.1.4",
"@connectrpc/connect": "^1.6.1",
"@connectrpc/connect-node": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.0.4",
"@opentelemetry/api": "^1.9.0",
"@streamparser/json": "^0.0.21",
"ajv": "^8.17.1",
Expand Down
55 changes: 55 additions & 0 deletions src/tools/mcp/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright 2025 IBM Corp.
*
* 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 { Client as MCPClient } from "@modelcontextprotocol/sdk/client/index.js";

import { Tool, JSONToolOutput } from "@/tools/base.js";

export interface MCPToolInput {
client: MCPClient;
}

export class MCPToolOutput<T> extends JSONToolOutput<T> {}

export abstract class MCPTool<T> extends Tool<MCPToolOutput<T>> {
public readonly client: MCPClient;

constructor({ client, ...options }: MCPToolInput) {
super(options);
this.client = client;
}

protected async paginateUntilLimit<T>(
fetcher: (
params: { cursor?: string },
options: { signal?: AbortSignal },
) => Promise<{ nextCursor?: string; items: T[] }>,
limit: number,
{ signal }: { signal?: AbortSignal } = {},
) {
const items: T[] = [];
let cursor: string | undefined = undefined;
while (items.length < limit) {
const result = await fetcher({ cursor }, { signal });
items.push(...result.items);
cursor = result.nextCursor;
if (!cursor) {
break;
}
}
return items.slice(0, limit);
}
}
122 changes: 122 additions & 0 deletions src/tools/mcp/mcpResource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Copyright 2025 IBM Corp.
*
* 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 { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { MCPResourceTool } from "./mcpResource.js";

describe("MCPResourceTool", () => {
const server = new Server(
{
name: "test-server",
version: "1.0.0",
},
{
capabilities: {
resources: {},
},
},
);

const client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{
capabilities: {},
},
);

let instance: MCPResourceTool;

beforeAll(async () => {
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "file:///animals.txt",
name: "Info about animals",
},
{
uri: "file:///planets.txt",
name: "Info about planets",
},
],
};
});
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
return {
resourceTemplates: [
{
uriTemplate: "file:///animals.txt",
name: "Info about animals",
},
],
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === "file:///animals.txt") {
return {
contents: [
{
uri: "file:///animals.txt",
mimeType: "text/plain",
text: "Lion, shark, bizon",
},
],
};
} else if (request.params.uri === "file:///planets.txt") {
return {
contents: [
{
uri: "file:///planets.txt",
mimeType: "text/plain",
text: "Earth, Mars, Venus",
},
],
};
} else {
throw new Error("Resource not found");
}
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
});

beforeEach(() => {
instance = new MCPResourceTool({ client });
});

describe("MCPTool", () => {
it("Runs", async () => {
const response = await instance.run({ uri: "file:///planets.txt" });
expect(response.result).toBeDefined();
});
});

afterAll(async () => {
await server.close();
});
});
70 changes: 70 additions & 0 deletions src/tools/mcp/mcpResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright 2025 IBM Corp.
*
* 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 { ToolEmitter, ToolInput } from "@/tools/base.js";
import { z } from "zod";
import { Emitter } from "@/emitter/emitter.js";
import { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { MCPTool, MCPToolInput, MCPToolOutput } from "./base.js";

export interface MCPResourceToolInput extends MCPToolInput {
resourceLimit?: number;
}

export class MCPResourceTool extends MCPTool<ReadResourceResult> {
name = "MCP Resource";
description = `An MCP Resource tool provides ability to read resources. Use it to read contents of available resources.`;

public readonly emitter: ToolEmitter<ToolInput<this>, MCPToolOutput<ReadResourceResult>> =
Emitter.root.child({
namespace: ["tool", "mcp", "resource"],
creator: this,
});

public readonly resourceLimit: number;

constructor({ resourceLimit = 50, ...options }: MCPResourceToolInput) {
super(options);
this.resourceLimit = resourceLimit;
}

async inputSchema() {
const resources = await this.listPaginatedResources();
return z.object({
uri: z
.string()
.describe(
`URI of the resource to read, ${resources.length > 0 ? `available resources are:\n\n${resources.map(({ uri, name, description }) => JSON.stringify({ uri, name, description })).join("\n\n")}` : "no resources available at the moment"}.`,
),
});
}

protected async _run({ uri }: ToolInput<this>, { signal }: { signal?: AbortSignal }) {
const result = await this.client.readResource({ uri }, { signal });
return new MCPToolOutput(result);
}

private async listPaginatedResources({ signal }: { signal?: AbortSignal } = {}) {
return await this.paginateUntilLimit(
({ cursor }, { signal }) =>
this.client
.listResources({ cursor }, { signal })
.then(({ resources, nextCursor }) => ({ items: resources, nextCursor })),
this.resourceLimit,
{ signal },
);
}
}
Loading

0 comments on commit 9f04e7f

Please sign in to comment.