From e8b03d432fd09e7e25e3f272f735cebeb571a31b Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Tue, 10 Dec 2024 00:00:52 +0100 Subject: [PATCH 1/3] feat: add resources support --- src/core/MCPServer.ts | 123 +++++++++++++++++++++++++------ src/core/resourceLoader.ts | 133 ++++++++++++++++++++++++++++++++++ src/index.ts | 8 ++ src/resources/BaseResource.ts | 65 +++++++++++++++++ 4 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 src/core/resourceLoader.ts create mode 100644 src/resources/BaseResource.ts diff --git a/src/core/MCPServer.ts b/src/core/MCPServer.ts index 3759ae2..2bf2cfd 100644 --- a/src/core/MCPServer.ts +++ b/src/core/MCPServer.ts @@ -5,11 +5,17 @@ import { ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { ToolLoader } from "./toolLoader.js"; import { PromptLoader } from "./promptLoader.js"; +import { ResourceLoader } from "./resourceLoader.js"; import { ToolProtocol } from "../tools/BaseTool.js"; import { PromptProtocol } from "../prompts/BasePrompt.js"; +import { ResourceProtocol } from "../resources/BaseResource.js"; import { readFileSync } from "fs"; import { join, dirname } from "path"; import { logger } from "./Logger.js"; @@ -30,14 +36,19 @@ export type ServerCapabilities = { prompts?: { enabled: true; }; + resources?: { + enabled: true; + }; }; export class MCPServer { private server: Server; private toolsMap: Map = new Map(); private promptsMap: Map = new Map(); + private resourcesMap: Map = new Map(); private toolLoader: ToolLoader; private promptLoader: PromptLoader; + private resourceLoader: ResourceLoader; private serverName: string; private serverVersion: string; private basePath: string; @@ -53,6 +64,7 @@ export class MCPServer { this.toolLoader = new ToolLoader(this.basePath); this.promptLoader = new PromptLoader(this.basePath); + this.resourceLoader = new ResourceLoader(this.basePath); this.server = new Server( { @@ -63,6 +75,7 @@ export class MCPServer { capabilities: { tools: { enabled: true }, prompts: { enabled: false }, + resources: { enabled: false }, }, } ); @@ -162,6 +175,66 @@ export class MCPServer { messages: await prompt.getMessages(request.params.arguments), }; }); + + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: Array.from(this.resourcesMap.values()).map( + (resource) => resource.resourceDefinition + ), + }; + }); + + this.server.setRequestHandler( + ReadResourceRequestSchema, + async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error( + `Unknown resource: ${ + request.params.uri + }. Available resources: ${Array.from(this.resourcesMap.keys()).join( + ", " + )}` + ); + } + + return { + contents: await resource.read(), + }; + } + ); + + this.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error(`Unknown resource: ${request.params.uri}`); + } + + if (!resource.subscribe) { + throw new Error( + `Resource ${request.params.uri} does not support subscriptions` + ); + } + + await resource.subscribe(); + return {}; + }); + + this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error(`Unknown resource: ${request.params.uri}`); + } + + if (!resource.unsubscribe) { + throw new Error( + `Resource ${request.params.uri} does not support subscriptions` + ); + } + + await resource.unsubscribe(); + return {}; + }); } private async detectCapabilities(): Promise { @@ -170,15 +243,16 @@ export class MCPServer { if (await this.toolLoader.hasTools()) { capabilities.tools = { enabled: true }; logger.debug("Tools capability enabled"); - } else { - logger.debug("No tools found, tools capability disabled"); } if (await this.promptLoader.hasPrompts()) { capabilities.prompts = { enabled: true }; logger.debug("Prompts capability enabled"); - } else { - logger.debug("No prompts found, prompts capability disabled"); + } + + if (await this.resourceLoader.hasResources()) { + capabilities.resources = { enabled: true }; + logger.debug("Resources capability enabled"); } return capabilities; @@ -196,30 +270,37 @@ export class MCPServer { prompts.map((prompt: PromptProtocol) => [prompt.name, prompt]) ); - this.detectCapabilities(); + const resources = await this.resourceLoader.loadResources(); + this.resourcesMap = new Map( + resources.map((resource: ResourceProtocol) => [resource.uri, resource]) + ); + + await this.detectCapabilities(); const transport = new StdioServerTransport(); await this.server.connect(transport); - if (tools.length > 0 || prompts.length > 0) { + logger.info(`Started ${this.serverName}@${this.serverVersion}`); + + if (tools.length > 0) { logger.info( - `Started ${this.serverName}@${this.serverVersion} with ${tools.length} tools and ${prompts.length} prompts` + `Tools (${tools.length}): ${Array.from(this.toolsMap.keys()).join( + ", " + )}` ); - if (tools.length > 0) { - logger.info( - `Available tools: ${Array.from(this.toolsMap.keys()).join(", ")}` - ); - } - if (prompts.length > 0) { - logger.info( - `Available prompts: ${Array.from(this.promptsMap.keys()).join( - ", " - )}` - ); - } - } else { + } + if (prompts.length > 0) { + logger.info( + `Prompts (${prompts.length}): ${Array.from( + this.promptsMap.keys() + ).join(", ")}` + ); + } + if (resources.length > 0) { logger.info( - `Started ${this.serverName}@${this.serverVersion} with no tools or prompts` + `Resources (${resources.length}): ${Array.from( + this.resourcesMap.keys() + ).join(", ")}` ); } } catch (error) { diff --git a/src/core/resourceLoader.ts b/src/core/resourceLoader.ts new file mode 100644 index 0000000..f68ab1d --- /dev/null +++ b/src/core/resourceLoader.ts @@ -0,0 +1,133 @@ +import { ResourceProtocol } from "../resources/BaseResource.js"; +import { join, dirname } from "path"; +import { promises as fs } from "fs"; +import { logger } from "./Logger.js"; + +export class ResourceLoader { + private readonly RESOURCES_DIR: string; + private readonly EXCLUDED_FILES = [ + "BaseResource.js", + "*.test.js", + "*.spec.js", + ]; + + constructor(basePath?: string) { + const mainModulePath = basePath || process.argv[1]; + this.RESOURCES_DIR = join(dirname(mainModulePath), "resources"); + logger.debug( + `Initialized ResourceLoader with directory: ${this.RESOURCES_DIR}` + ); + } + + async hasResources(): Promise { + try { + const stats = await fs.stat(this.RESOURCES_DIR); + if (!stats.isDirectory()) { + logger.debug("Resources path exists but is not a directory"); + return false; + } + + const files = await fs.readdir(this.RESOURCES_DIR); + const hasValidFiles = files.some((file) => this.isResourceFile(file)); + logger.debug(`Resources directory has valid files: ${hasValidFiles}`); + return hasValidFiles; + } catch (error) { + logger.debug("No resources directory found"); + return false; + } + } + + private isResourceFile(file: string): boolean { + if (!file.endsWith(".js")) return false; + const isExcluded = this.EXCLUDED_FILES.some((pattern) => { + if (pattern.includes("*")) { + const regex = new RegExp(pattern.replace("*", ".*")); + return regex.test(file); + } + return file === pattern; + }); + + logger.debug( + `Checking file ${file}: ${isExcluded ? "excluded" : "included"}` + ); + return !isExcluded; + } + + private validateResource(resource: any): resource is ResourceProtocol { + const isValid = Boolean( + resource && + typeof resource.uri === "string" && + typeof resource.name === "string" && + resource.resourceDefinition && + typeof resource.read === "function" + ); + + if (isValid) { + logger.debug(`Validated resource: ${resource.name}`); + } else { + logger.warn(`Invalid resource found: missing required properties`); + } + + return isValid; + } + + async loadResources(): Promise { + try { + logger.debug(`Attempting to load resources from: ${this.RESOURCES_DIR}`); + + let stats; + try { + stats = await fs.stat(this.RESOURCES_DIR); + } catch (error) { + logger.debug("No resources directory found"); + return []; + } + + if (!stats.isDirectory()) { + logger.error(`Path is not a directory: ${this.RESOURCES_DIR}`); + return []; + } + + const files = await fs.readdir(this.RESOURCES_DIR); + logger.debug(`Found files in directory: ${files.join(", ")}`); + + const resources: ResourceProtocol[] = []; + + for (const file of files) { + if (!this.isResourceFile(file)) { + continue; + } + + try { + const fullPath = join(this.RESOURCES_DIR, file); + logger.debug(`Attempting to load resource from: ${fullPath}`); + + const importPath = `file://${fullPath}`; + const { default: ResourceClass } = await import(importPath); + + if (!ResourceClass) { + logger.warn(`No default export found in ${file}`); + continue; + } + + const resource = new ResourceClass(); + if (this.validateResource(resource)) { + resources.push(resource); + } + } catch (error) { + logger.error(`Error loading resource ${file}: ${error}`); + } + } + + logger.debug( + `Successfully loaded ${resources.length} resources: ${resources + .map((r) => r.name) + .join(", ")}` + ); + return resources; + } catch (error) { + logger.error(`Failed to load resources: ${error}`); + return []; + } + } +} diff --git a/src/index.ts b/src/index.ts index 5a44f73..28b24ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,13 @@ export { type PromptArgumentSchema, type PromptArguments, } from "./prompts/BasePrompt.js"; +export { + MCPResource, + type ResourceProtocol, + type ResourceContent, + type ResourceDefinition, + type ResourceTemplateDefinition, +} from "./resources/BaseResource.js"; export { ToolLoader } from "./core/toolLoader.js"; export { PromptLoader } from "./core/promptLoader.js"; +export { ResourceLoader } from "./core/resourceLoader.js"; diff --git a/src/resources/BaseResource.ts b/src/resources/BaseResource.ts new file mode 100644 index 0000000..c681ed9 --- /dev/null +++ b/src/resources/BaseResource.ts @@ -0,0 +1,65 @@ +export type ResourceContent = { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +}; + +export type ResourceDefinition = { + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +export type ResourceTemplateDefinition = { + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; +}; + +export interface ResourceProtocol { + uri: string; + name: string; + description?: string; + mimeType?: string; + resourceDefinition: ResourceDefinition; + read(): Promise; + subscribe?(): Promise; + unsubscribe?(): Promise; +} + +export abstract class MCPResource implements ResourceProtocol { + abstract uri: string; + abstract name: string; + description?: string; + mimeType?: string; + + get resourceDefinition(): ResourceDefinition { + return { + uri: this.uri, + name: this.name, + description: this.description, + mimeType: this.mimeType, + }; + } + + abstract read(): Promise; + + async subscribe?(): Promise { + throw new Error("Subscription not implemented for this resource"); + } + + async unsubscribe?(): Promise { + throw new Error("Unsubscription not implemented for this resource"); + } + + protected async fetch(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + } +} From ed525c4878146a7fd5f9a5ff95498bbc71f0f8a5 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Tue, 10 Dec 2024 00:03:13 +0100 Subject: [PATCH 2/3] refactor: loaders moved to separate dir --- src/core/MCPServer.ts | 6 +++--- src/index.ts | 6 +++--- src/{core => loaders}/promptLoader.ts | 2 +- src/{core => loaders}/resourceLoader.ts | 2 +- src/{core => loaders}/toolLoader.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/{core => loaders}/promptLoader.ts (98%) rename src/{core => loaders}/resourceLoader.ts (98%) rename src/{core => loaders}/toolLoader.ts (98%) diff --git a/src/core/MCPServer.ts b/src/core/MCPServer.ts index 2bf2cfd..57d2391 100644 --- a/src/core/MCPServer.ts +++ b/src/core/MCPServer.ts @@ -10,15 +10,15 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { ToolLoader } from "./toolLoader.js"; -import { PromptLoader } from "./promptLoader.js"; -import { ResourceLoader } from "./resourceLoader.js"; import { ToolProtocol } from "../tools/BaseTool.js"; import { PromptProtocol } from "../prompts/BasePrompt.js"; import { ResourceProtocol } from "../resources/BaseResource.js"; import { readFileSync } from "fs"; import { join, dirname } from "path"; import { logger } from "./Logger.js"; +import { ToolLoader } from "../loaders/toolLoader.js"; +import { PromptLoader } from "../loaders/promptLoader.js"; +import { ResourceLoader } from "../loaders/resourceLoader.js"; export interface MCPServerConfig { name?: string; diff --git a/src/index.ts b/src/index.ts index 28b24ae..1e3446e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,6 @@ export { type ResourceDefinition, type ResourceTemplateDefinition, } from "./resources/BaseResource.js"; -export { ToolLoader } from "./core/toolLoader.js"; -export { PromptLoader } from "./core/promptLoader.js"; -export { ResourceLoader } from "./core/resourceLoader.js"; +export { ToolLoader } from "./loaders/toolLoader.js"; +export { PromptLoader } from "./loaders/promptLoader.js"; +export { ResourceLoader } from "./loaders/resourceLoader.js"; diff --git a/src/core/promptLoader.ts b/src/loaders/promptLoader.ts similarity index 98% rename from src/core/promptLoader.ts rename to src/loaders/promptLoader.ts index 7047026..ddd13cc 100644 --- a/src/core/promptLoader.ts +++ b/src/loaders/promptLoader.ts @@ -1,7 +1,7 @@ import { PromptProtocol } from "../prompts/BasePrompt.js"; import { join, dirname } from "path"; import { promises as fs } from "fs"; -import { logger } from "./Logger.js"; +import { logger } from "../core/Logger.js"; export class PromptLoader { private readonly PROMPTS_DIR: string; diff --git a/src/core/resourceLoader.ts b/src/loaders/resourceLoader.ts similarity index 98% rename from src/core/resourceLoader.ts rename to src/loaders/resourceLoader.ts index f68ab1d..a6c94e0 100644 --- a/src/core/resourceLoader.ts +++ b/src/loaders/resourceLoader.ts @@ -1,7 +1,7 @@ import { ResourceProtocol } from "../resources/BaseResource.js"; import { join, dirname } from "path"; import { promises as fs } from "fs"; -import { logger } from "./Logger.js"; +import { logger } from "../core/Logger.js"; export class ResourceLoader { private readonly RESOURCES_DIR: string; diff --git a/src/core/toolLoader.ts b/src/loaders/toolLoader.ts similarity index 98% rename from src/core/toolLoader.ts rename to src/loaders/toolLoader.ts index e174746..4722322 100644 --- a/src/core/toolLoader.ts +++ b/src/loaders/toolLoader.ts @@ -1,7 +1,7 @@ import { ToolProtocol } from "../tools/BaseTool.js"; import { join, dirname } from "path"; import { promises as fs } from "fs"; -import { logger } from "./Logger.js"; +import { logger } from "../core/Logger.js"; export class ToolLoader { private readonly TOOLS_DIR: string; From 6c1efcde6bcef838aaacc52e6e41903025e5ecc5 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Tue, 10 Dec 2024 00:17:47 +0100 Subject: [PATCH 3/3] feat: update README --- README.md | 201 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 151 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 30cf383..9025ba5 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,29 @@ # mcp-framework -A framework for building Model Context Protocol (MCP) servers with automatic tool loading and management in Typescript. +A framework for building Model Context Protocol (MCP) servers elegantly in TypeScript. Get started fast with mcp-framework โšกโšกโšก ## Features -- ๐Ÿ› ๏ธ Automatic tool discovery and loading -- ๐Ÿ—๏ธ Base tool implementation with helper methods -- โš™๏ธ Configurable tool directory and exclusions -- ๐Ÿ”’ Type-safe tool validation +- ๐Ÿ› ๏ธ Automatic directory-based discovery and loading for tools, prompts, and resources +- ๐Ÿ—๏ธ Powerful abstractions - ๐Ÿš€ Simple server setup and configuration -- ๐Ÿ› Built-in error handling and logging ## Installation ```bash -npm install mcp-framework @modelcontextprotocol/sdk +npm install mcp-framework ``` ## Quick Start -1. Create your MCP server: +### 1. Create your MCP server: ```typescript import { MCPServer } from "mcp-framework"; -const server = new MCPServer({ - name: "my-mcp-server", - version: "1.0.0", - toolsDir: "./dist/tools", // Optional: defaults to dist/tools -}); +const server = new MCPServer(); server.start().catch((error) => { console.error("Server failed to start:", error); @@ -38,68 +31,176 @@ server.start().catch((error) => { }); ``` -2. Create a tool by extending BaseToolImplementation: +### 2. Create a Tool: ```typescript -import { BaseToolImplementation } from "mcp-framework"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { MCPTool } from "mcp-framework"; +import { z } from "zod"; + +interface ExampleInput { + message: string; +} -class ExampleTool extends BaseToolImplementation { +class ExampleTool extends MCPTool { name = "example_tool"; - toolDefinition: Tool = { - name: this.name, - description: "An example tool", - inputSchema: { - type: "object", - properties: { - input: { - type: "string", - description: "Input parameter", - }, - }, + description = "An example tool that processes messages"; + + schema = { + message: { + type: z.string(), + description: "Message to process", }, }; - async toolCall(request: any) { - try { - const input = request.params.arguments?.input; - if (!input) { - throw new Error("Missing input parameter"); - } - - const result = `Processed: ${input}`; - return this.createSuccessResponse(result); - } catch (error) { - return this.createErrorResponse(error); - } + async execute(input: ExampleInput) { + return `Processed: ${input.message}`; } } export default ExampleTool; ``` -## Configuration +### 3. Create a Prompt: -### MCPServer Options +```typescript +import { MCPPrompt } from "mcp-framework"; +import { z } from "zod"; + +interface GreetingInput { + name: string; + language?: string; +} -- `name`: Server name -- `version`: Server version -- `toolsDir`: Directory containing tool files (optional) -- `excludeTools`: Array of patterns for files to exclude (optional) +class GreetingPrompt extends MCPPrompt { + name = "greeting"; + description = "Generate a greeting in different languages"; -### Project Structure + schema = { + name: { + type: z.string(), + description: "Name to greet", + required: true, + }, + language: { + type: z.string().optional(), + description: "Language for greeting", + required: false, + }, + }; + + async generateMessages({ name, language = "English" }: GreetingInput) { + return [ + { + role: "user", + content: { + type: "text", + text: `Generate a greeting for ${name} in ${language}`, + }, + }, + ]; + } +} + +export default GreetingPrompt; +``` + +### 4. Create a Resource: + +```typescript +import { MCPResource, ResourceContent } from "mcp-framework"; + +class ConfigResource extends MCPResource { + uri = "config://app/settings"; + name = "Application Settings"; + description = "Current application configuration"; + mimeType = "application/json"; + + async read(): Promise { + const config = { + theme: "dark", + language: "en", + }; + + return [ + { + uri: this.uri, + mimeType: this.mimeType, + text: JSON.stringify(config, null, 2), + }, + ]; + } +} + +export default ConfigResource; +``` + +## Project Structure ``` your-project/ โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ tools/ -โ”‚ โ”‚ โ”œโ”€โ”€ ExampleTool.ts -โ”‚ โ”‚ โ””โ”€โ”€ OtherTool.ts +โ”‚ โ”œโ”€โ”€ tools/ # Tool implementations +โ”‚ โ”‚ โ””โ”€โ”€ ExampleTool.ts +โ”‚ โ”œโ”€โ”€ prompts/ # Prompt implementations +โ”‚ โ”‚ โ””โ”€โ”€ GreetingPrompt.ts +โ”‚ โ”œโ”€โ”€ resources/ # Resource implementations +โ”‚ โ”‚ โ””โ”€โ”€ ConfigResource.ts โ”‚ โ””โ”€โ”€ index.ts โ”œโ”€โ”€ package.json โ””โ”€โ”€ tsconfig.json ``` +## Automatic Feature Discovery + +The framework automatically discovers and loads: + +- Tools from the `src/tools` directory +- Prompts from the `src/prompts` directory +- Resources from the `src/resources` directory + +Each feature should be in its own file and export a default class that extends the appropriate base class: + +- `MCPTool` for tools +- `MCPPrompt` for prompts +- `MCPResource` for resources + +### Base Classes + +#### MCPTool + +- Handles input validation using Zod +- Provides error handling and response formatting +- Includes fetch helper for HTTP requests + +#### MCPPrompt + +- Manages prompt arguments and validation +- Generates message sequences for LLM interactions +- Supports dynamic prompt templates + +#### MCPResource + +- Exposes data through URI-based system +- Supports text and binary content +- Optional subscription capabilities for real-time updates + +## Type Safety + +All features use Zod for runtime type validation and TypeScript for compile-time type checking. Define your input schemas using Zod types: + +```typescript +schema = { + parameter: { + type: z.string().email(), + description: "User email address", + }, + count: { + type: z.number().min(1).max(100), + description: "Number of items", + }, +}; +``` + ## License MIT