diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..3b7f14a --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,41 @@ +name: Test, Build, and Release +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test-build-release: + name: Test, Build, and Release + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test + + - name: Build + run: bun run build + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun run semantic-release diff --git a/.gitignore b/.gitignore index 9b1ee42..a962fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ dist # Finder (MacOS) folder config .DS_Store + +node_modules +dist diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..61cacc6 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,17 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + [ + "@semantic-release/git", + { + "assets": ["package.json", "CHANGELOG.md"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/LICENSE b/LICENSE index 52024a4..fc4c379 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Zach Caceres (zach.dot / @zachcaceres) +Copyright (c) 2025 Zach Caceres (zach.dev / @zachcaceres) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b9d8be7..17866a1 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,216 @@ # easy-mcp -> EasyMCP is in beta, with a first release coming in January 2025. Please report any issues you encounter. +> EasyMCP is usable but in beta. Please report any issues you encounter. -Easy MCP is the simplest way to create Model Context Protocol (MCP) servers in TypeScript. +EasyMCP is the simplest way to create Model Context Protocol (MCP) servers in TypeScript. -It hides the plumbing and definitions behind simple, easy-to-use functions, allowing you to focus on building your server. Easy MCP strives to mimic the syntax of popular server frameworks like Express, making it easy to get started. +It hides the plumbing, formatting, and other boilerplate definitions behind simple declarations. -Easy MCP allows you to define the bare minimum of what you need to get started, or you can define more complex resources, templates, tools, and prompts. +Easy MCP allows you to define the bare minimum of what you need to get started. Or you can define more complex resources, templates, tools, and prompts. + +## Features + +- **Simple Express-like API**: EasyMCP provides a high-level, intuitive API. Define Tools, Prompts, Resources, Resource Templates, and Roots with calls similar to defining endpoints in ExpressJS. Every possible parameter that could be optional is optional and hidden unless you need it. +- **Experimental Decorators API**: Automagically infers tool, prompt, and resource arguments. No input schema definition required! +- **Context Object**: Access MCP capabilities like logging and progress reporting through a context object in your tools. +- **Great Type Safety**: Better DX and fewer runtime errors. + +## Beta Limitations + +- No support for MCP sampling, yet +- No support for SSE, yet ## Installation To install easy-mcp, run the following command in your project directory: ```bash -pnpm install easy-mcp +bun install ``` -Or if you're using bun: +## Quick Start with (Experimental) Decorators API -```bash -bun add easy-mcp +Also see `examples/express-decorators.ts` or run `bun start:decorators` + +EasyMCP's decorator API is dead simple and infers types and input configuration automatically. + +But it's *experimental* and may change or have not-yet-discovered problems. + +```typescript +import EasyMCP from "./lib/EasyMCP"; +import { Tool, Resource, Prompt } from "./lib/experimental/decorators"; + +class MyMCP extends EasyMCP { + + @Resource("greeting/{name}") + getGreeting(name: string) { + return `Hello, ${name}!`; + } + + @Prompt() + greetingPrompt(name: string) { + return `Generate a greeting for ${name}.`; + } + + @Tool() + greet(name: string, optionalContextFromServer: Context) { + optionalContextFromServer.info(`Greeting ${name}`); + return `Hello, ${name}!`; + } +} + +const mcp = new MyMCP({ version: "1.0.0" }); ``` -## Limitations +## Complex Example with Decorators API -- No support for sampling -- No support for SSE +See `examples/express-express.ts` or run `bun start:express` -## Usage +```typescript +import EasyMCP from "./lib/EasyMCP"; +import { Prompt } from "./lib/decorators/Prompt"; +import { Resource } from "./lib/decorators/Resource"; +import { Root } from "./lib/decorators/Root"; +import { Tool } from "./lib/decorators/Tool"; + +@Root("/my-sample-dir/photos") +@Root("/my-root-dir", { name: "My laptop's root directory" }) // Optionally you can name the root +class ZachsMCP extends EasyMCP { + /** + You can declare a Tool with zero configuration. Relevant types and plumbing will be inferred and handled. + + By default, the name of the Tool will be the name of the method. + */ + @Tool() + simpleFunc(nickname: string, height: number) { + return `${nickname} of ${height} height`; + } + + /** + * You can enhance a tool with optional data like a description. + + Due to limitations in Typescript, if you want the Tool to serialize certain inputs as optional to the Client, you need to provide an optionals list. + */ + @Tool({ + description: "An optional description", + optionals: ["active", "items", "age"], + }) + middleFunc(name: string, active?: string, items?: string[], age?: number) { + return `exampleFunc called: name ${name}, active ${active}, items ${items}, age ${age}`; + } + + /** + * You can also provide a schema for the input arguments of a tool, if you want full control. + */ + @Tool({ + description: "A function with various parameter types", + parameters: [ + { + name: "date", + type: "string", + optional: false, + }, + { + name: "season", + type: "string", + optional: false, + }, + { + name: "year", + type: "number", + optional: true, + }, + ], + }) + complexTool(date: string, season: string, year?: number) { + return `complexTool called: date ${date}, season ${season}, year ${year}`; + } + + /** + * Tools can use a context object to access MCP capabilities like logging, progress reporting, and meta data from the request + */ + @Tool({ + description: "A tool that uses context", + }) + async processData(dataSource: string, context: Context) { + context.info(`Starting to process data from ${dataSource}`); + + try { + const data = await context.readResource(dataSource); + context.debug("Data loaded"); + + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await context.reportProgress(i * 20, 100); + context.info(`Processing step ${i + 1} complete`); + } + + return `Processed ${data.length} bytes of data from ${dataSource}`; + } catch (error) { + context.error(`Error processing data: ${(error as Error).message}`); + throw error; + } + } + + /** + * Resources can be declared with a simple URI. + + By default, the name of the resource will be the name of the method. + */ + @Resource("simple-resource") + simpleResource() { + return "Hello, world!"; + } + + /** + * Or include handlebars which EasyMCP will treat as a Resource Template. + + Both Resources and Resource Templates can be configured with optional data like a description. + */ + @Resource("greeting/{name}") + myResourceTemplate(name: string) { + return `Hello, ${name}!`; + } + + /** + * By default, prompts need no configuration. + + They will be named after the method they decorate. + */ + @Prompt() + simplePrompt(name: string) { + return `Prompting... ${name}`; + } + + /** + * Or you can override and configure a Prompt with a name, description, and explicit arguments. + */ + @Prompt({ + name: "configured-prompt", + description: "A prompt with a name and description", + args: [ + { + name: "name", + description: "The name of the thing to prompt", + required: true, + }, + ], + }) + configuredPrompt(name: string) { + return `Prompting... ${name}`; + } +} + +const mcp = new ZachsMCP({ version: "1.0.0" }); +console.log(mcp.name, "is now serving!"); + +``` -Here's a basic example of how to use easy-mcp: +## Quick Start with Express-like API + +Also see `examples/example-minimal.ts` or run `bun start:express` + +This API is more verbose and less magical, but it's more stable and tested. ```typescript import EasyMCP from "easy-mcp"; @@ -98,7 +279,7 @@ mcp.prompt({ mcp.serve().catch(console.error); ``` -## API +## Express-Like API ### `EasyMCP.create(name: string, options: ServerOptions)` @@ -131,9 +312,94 @@ Defines a root. Starts the MCP server. +## (Experimental) Decorator API + +EasyMCP provides decorators for a more concise and declarative way to define your MCP server components. Here's an overview of the available decorators: + +### `@Tool(config?: ToolConfig)` + +Defines a method as a tool. The method will take in any arguments you declare and infer types and input configurations based on your TS annotations. An optional `context` argument can be added as the last argument to access MCP capabilities. + +- `config`: Optional configuration object for the tool. + - `description`: Optional description of the tool. + - `optionals`: Optional array of parameter names that should be marked as optional. + - `parameters`: Optional array of parameter definitions for full control over the input schema. + +Example: +```typescript +@Tool({ + description: "Greets a person", + optionals: ["title"], +}) +greet(name: string, title?: string, optionalContext: Context) { + return `Hello, ${title ? title + " " : ""}${name}!`; +} +``` + +### `@Resource(uri: string, config?: Partial)` + +Defines a method as a resource or resource template. A resource template is defined by using handlebars in the URI. + +- `uri`: The URI or URI template for the resource. +- `config`: Optional configuration object for the resource. + - `name`: Optional name for the resource. + - `description`: Optional description of the resource. + - `mimeType`: Optional MIME type of the resource. + +Example: +```typescript +@Resource("greeting/{name}") +getGreeting(name: string) { + return `Hello, ${name}!`; +} +``` + +### `@Prompt(config?: PromptDefinition)` + +Defines a method as a prompt. + +- `config`: Optional configuration object for the prompt. + - `name`: Optional name for the prompt (defaults to method name). + - `description`: Optional description of the prompt. + - `args`: Optional array of argument definitions. + +Example: +```typescript +@Prompt({ + description: "Generates a greeting prompt", + args: [ + { name: "name", description: "Name to greet", required: true }, + ], +}) +greetingPrompt(name: string) { + return `Generate a friendly greeting for ${name}.`; +} +``` + +### `@Root(uri: string, config?: { name?: string })` + +Defines a root directory for the MCP server. This decorator is applied to the class, not to a method. + +- `uri`: The URI of the root directory. +- `config`: Optional configuration object. + - `name`: Optional name for the root. + +Example: +```typescript +@Root("/my-sample-dir/photos") +@Root("/my-root-dir", { name: "My laptop's root directory" }) +class MyMCP extends EasyMCP { + // ... +} +``` + +> When using decorators, EasyMCP will automatically infer types and create appropriate configurations for your tools, resources, prompts, and roots. This can significantly reduce boilerplate code and make your MCP server definition more concise. + +But... the decorator API is experimental and may have bugs or unexpected changes + ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Just submit a PR. ## License diff --git a/TODO.md b/TODO.md index 1e39971..0cbb511 100644 --- a/TODO.md +++ b/TODO.md @@ -1,50 +1,21 @@ ## TODO -3. list capabilities based on what has been registered -1. The formatting of messages (TS seems less tolerant than Py) probably needs to be hidden inside the Resources and Tools modules. +NEXT: +- LOGO +- GH workflows for releases and test running +- For the experimental API: add error to make sure people don't pass objects as args to the functions that are decorated. +- Prompt in theory accepts inputs but, the TS types don't suggest it can and it doesn't seem to share them. +- Conversion and formatting of messages and responses - What are the supported data types -2. Parse function signature for input arguments - -``` -Server Class - Managers - - [X] Resource Manager - - [X] Tool Manager - Prompt Managers - Core Handlers - - [X] list resource - - [X] read resource - - [X] add resource - - [] sub - - [] unsub - - [X] list tools - - [X] read tool - - [X] add tool -- [X] call tool - - [X] list prompts - - [X] get prompt -``` - -### Server Concepts -- [X] customize the transport layer -- [X] Declare the capabilities of the server in the constructor - - -### Capabilities -- [X] Tools -- [X] Prompts -- [X] Resources -- [] Roots -- [] Conversion flow / message wrappers -- [] Context Object - - [] Logging via debug(), info(), warning(), and error() - - [] Resource access through read_resource() - - [] Request metadata via request_id and client_id -- [] Image conversion - -### Test Coverage -- [X] Tests on each major capability -- [] Test with Claude Desktop + Textcontent + ImageContent + EmbeddedResource + PromptMessage + BlobResourceContents + TextResourceContents + ResourceContents + - [] Image conversion +- sub / unsub ### Example Servers - [] Weather @@ -58,7 +29,5 @@ Server Class ### Polish - [] dependencies -- [] Can we infer the inputs schema definition just from the type signature of the fn that's passed in when defining a tool? - -- [] Samplings (delay) +- [] Samplings (later) - [] SSE diff --git a/bun.lockb b/bun.lockb index 01ca4bc..0014162 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/example-complete.ts b/example-complete.ts deleted file mode 100644 index bd73cd9..0000000 --- a/example-complete.ts +++ /dev/null @@ -1,64 +0,0 @@ -import EasyMCP from "./lib/EasyMCP"; - -const mcp = EasyMCP.create("test-mcp", { - version: "0.1.0", -}); - -mcp.resource({ - uri: "dir://desktop", - name: "An optional name", - description: "An optional description", - mimeType: "text/plain", // optional - fn: async () => { - return "file://desktop/file1.txt"; - }, -}); - -mcp.template({ - uriTemplate: "file://{parameter1}/{parameter2}", - name: "An optional name", - description: "An optional description", - mimeType: "text/plain", // Optional - fn: async ({ filename }) => { - return `file://${filename}/file1.txt`; - }, -}); - -mcp.tool({ - name: "hello world", - inputs: [ - { - name: "optionalInput", - type: "string", - description: "an optional input", - required: false, - }, - ], - fn: async ({ name }) => { - return `Hello, ${name}!`; - }, -}); - -mcp.prompt({ - name: "Hello World Prompt", - description: "A prompt that says hello", - // TODO: find a way to infer the args from the parameters input to fn below, so we don't have to explicitly define them here. - args: [ - { - name: "name", - type: "string", - description: "Your name", - required: true, - }, - ], - fn: async ({ name }: { name: string }) => { - return `Hello, ${name}!`; - }, -}); - -mcp.root({ - name: "Optional Name", - uri: "/Users/username/Desktop", -}); - -await mcp.serve().catch(console.error); diff --git a/examples/example-experimental-decorators.ts b/examples/example-experimental-decorators.ts new file mode 100644 index 0000000..e3eed6e --- /dev/null +++ b/examples/example-experimental-decorators.ts @@ -0,0 +1,138 @@ +import type { Context } from "../lib/Context"; +import EasyMCP from "../lib/EasyMCP"; +import { Prompt } from "../lib/experimental/decorators/Prompt"; +import { Resource } from "../lib/experimental/decorators/Resource"; +import { Root } from "../lib/experimental/decorators/Root"; +import { Tool } from "../lib/experimental/decorators/Tool"; + +@Root("/my-sample-dir/photos") // Name will be inferred from the URI +@Root("/my-root-dir", { name: "My laptop's root directory" }) +class ZachsMCP extends EasyMCP { + /** + You can declare a with zero configuration. Relevant types and plumbing will be inferred and handled. + + By default, the *name* of the Tool will be the name of the method. + */ + @Tool() + simpleFunc(nickname: string, height: number) { + return `${nickname} of ${height} height`; + } + + /** + * You can enhance a tool with optional data like a description. + + Due to limitations in Typescript, if you want the Tool to serialize certain inputs as optional to the Client, you need to provide an optionals list with the name of these parameters. + */ + @Tool({ + description: "An optional description", + optionals: ["active", "items", "age"], + }) + middleFunc(name: string, active?: string, items?: string[], age?: number) { + return `exampleFunc called: name ${name}, active ${active}, items ${items}, age ${age}`; + } + + /** + * You can also provide a schema for the input arguments of a tool, if you want full control. + */ + @Tool({ + description: "A function with various parameter types", + parameters: [ + { + name: "date", + type: "string", + optional: false, + }, + { + name: "season", + type: "string", + optional: false, + }, + { + name: "year", + type: "number", + optional: true, + }, + ], + }) + complexTool(date: string, season: string, year?: number) { + return `complexTool called: date ${date}, season ${season}, year ${year}`; + } + + @Tool({ + description: "A tool that uses context", + }) + async processData(dataSource: string, context: Context) { + context.info(`Starting to process data from ${dataSource}`); + + try { + const data = await context.readResource(dataSource); + context.debug("Data loaded"); + + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await context.reportProgress(i * 20, 100); + context.info(`Processing step ${i + 1} complete`); + } + + return `Processed ${data.length} bytes of data from ${dataSource}`; + } catch (error) { + context.error(`Error processing data: ${(error as Error).message}`); + throw error; + } + } + + /** + * Resources can be declared with a simple URI. + + By default, the name of the resource will be the name of the method. + */ + @Resource("simple-resource") + simpleResource() { + return "Hello, world!"; + } + + /** + * Or include handlebars which EasyMCP will treat as a Resource Template. + + Both Resources and Resource Templates can be configured with optional data like a description. + */ + @Resource("greeting/{name}") + myResourceTemplate(name: string) { + return `Hello, ${name}!`; + } + + /** + * By default, prompts need no configuration. + + They will be named after the method they decorate. + */ + @Prompt() + simplePrompt(name: string) { + return `Prompting... ${name}`; + } + + /** + * Or you can override and configure a Prompt with a name, description, and explicit arguments. + */ + @Prompt({ + name: "configured-prompt", + description: "A prompt with a name and description", + args: [ + { + name: "name", + description: "The name of the thing to prompt", + required: true, + }, + ], + }) + configuredPrompt(name: string) { + return `Prompting... ${name}`; + } +} + +const mcp = new ZachsMCP({ + version: "1.0.0", + description: "A sample MCP with decorators", +}); +console.log(mcp.name, "is now serving!"); +console.log("It has capabilities:", mcp.listCapabilities()); diff --git a/examples/example-express.ts b/examples/example-express.ts new file mode 100644 index 0000000..d548e23 --- /dev/null +++ b/examples/example-express.ts @@ -0,0 +1,101 @@ +import type { Context } from "../lib/Context"; +import BaseMCP from "../lib/EasyMCP"; + +const mcp = BaseMCP.create("test-mcp", { + version: "0.1.0", +}); + +mcp.resource({ + uri: "dir://desktop", + name: "An optional name", // optional + description: "An optional description", // optional + mimeType: "text/plain", // optional + fn: async () => { + return "file://desktop/file1.txt"; + }, +}); + +mcp.template({ + uriTemplate: "file://{parameter1}/{parameter2}", + name: "An optional name", // optional + description: "An optional description", // optional + mimeType: "text/plain", // optional + fn: async ({ filename }) => { + return `file://${filename}/file1.txt`; + }, +}); + +mcp.tool({ + name: "hello world", + inputs: [ + { + name: "optionalInput", + type: "string", + description: "an optional input", + required: false, + }, + ], + fn: async ({ name }, context: Context) => { + context.info("Hello, world!"); + const resourceContent = await context.readResource("some://resource/uri"); + await context.reportProgress(50, 100); + return `Hello, ${name}! Resource content: ${resourceContent}`; + }, +}); + +mcp.tool({ + name: "processData", + inputs: [ + { + name: "dataSource", + type: "string", + description: "URI of the data source", + required: true, + }, + ], + fn: async ({ dataSource }, context) => { + context.info(`Starting to process data from ${dataSource}`); + + try { + const data = await context.readResource(dataSource); + context.debug("Data loaded"); + + // Simulate processing + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await context.reportProgress(i * 20, 100); + context.info(`Processing step ${i + 1} complete`); + } + + return `Processed ${data.length} bytes of data from ${dataSource}`; + } catch (error) { + context.error(`Error processing data: ${(error as Error).message}`); + throw error; + } + }, +}); + +mcp.prompt({ + name: "Hello World Prompt", + description: "A prompt that says hello", + args: [ + { + name: "name", + type: "string", + description: "Your name", + required: true, + }, + ], + fn: async ({ name }: { name: string }) => { + return `Hello, ${name}!`; + }, +}); + +mcp.root({ + name: "Optional Name", + uri: "/Users/username/Desktop", +}); + +await mcp.serve().catch(console.error); +console.log(mcp.name, "is now serving!"); +console.log("It has capabilities:", mcp.listCapabilities()); diff --git a/example-minimal.ts b/examples/example-minimal.ts similarity index 88% rename from example-minimal.ts rename to examples/example-minimal.ts index 0d45cc4..a76ad4e 100644 --- a/example-minimal.ts +++ b/examples/example-minimal.ts @@ -1,6 +1,6 @@ -import EasyMCP from "./lib/EasyMCP"; +import BaseMCP from "./lib/EasyMCP"; -const mcp = EasyMCP.create("test-mcp", { +const mcp = BaseMCP.create("test-mcp", { version: "0.1.0", }); diff --git a/index.ts b/index.ts index 522bdd4..e54e7a0 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,6 @@ -import EasyMCP from "./lib/EasyMCP"; +import BaseMCP from "./lib/EasyMCP"; -const mcp = EasyMCP.create("test-mcp", { +const mcp = BaseMCP.create("test-mcp", { version: "0.1.0", }); @@ -19,11 +19,20 @@ mcp.template({ name: "An optional name", // Optional description: "An optional description", // Optional mimeType: "text/plain", // Optional - fn: async ({ filename, id }) => { - return `file://${filename}/${id}.txt`; - }, + fn: addMetadata( + async ({ filename, id }: { filename: string; id: string }) => { + return `file://${filename}/${id}.txt`; + }, + ), }); +function addMetadata(fn: Function) { + return function (...args: any[]) { + // Here you can add logic to parse the function and add metadata + return fn(...args); + }; +} + mcp.tool({ name: "hello world", inputs: [ @@ -42,7 +51,6 @@ mcp.tool({ mcp.prompt({ name: "Hello World Prompt", description: "A prompt that says hello", - // TODO: find a way to infer the args from the parameters input to fn below, so we don't have to explicitly define them here. args: [ { name: "name", diff --git a/lib/ContentTypes.ts b/lib/ContentTypes.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/Context.test.ts b/lib/Context.test.ts new file mode 100644 index 0000000..75be5b8 --- /dev/null +++ b/lib/Context.test.ts @@ -0,0 +1,133 @@ +import { expect, test, mock, beforeEach, describe } from "bun:test"; +import { Context } from "./Context"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import ResourceManager from "./ResourceManager"; +import EasyMCP from "./EasyMCP"; + +describe("Context", () => { + let mockServer: Server; + let mockResourceManager: ResourceManager; + let context: Context; + let mockMeta: any; + + beforeEach(() => { + mockServer = { + notification: mock(() => Promise.resolve()), + sendLoggingMessage: mock(() => {}), + } as unknown as Server; + + mockResourceManager = { + get: mock((uri: string) => + Promise.resolve({ + contents: [ + { uri, mimeType: "text/plain", text: "Mock resource content" }, + ], + }), + ), + } as unknown as ResourceManager; + + mockMeta = { + progressToken: "mock-progress-token", + someOtherMetadata: "value", + }; + + context = new Context(mockServer, mockResourceManager, mockMeta); + }); + + test("reportProgress sends notification with correct data", async () => { + await context.reportProgress(50, 100); + + expect(mockServer.notification).toHaveBeenCalledWith({ + method: "notifications/progress", + params: { + progressToken: "mock-progress-token", + progress: 50, + total: 100, + }, + }); + }); + + test("readResource returns content for valid URI", async () => { + const content = await context.readResource("test://uri"); + + expect(content).toBe("Mock resource content"); + expect(mockResourceManager.get).toHaveBeenCalledWith("test://uri"); + }); + + test("readResource throws error for non-existent resource", async () => { + mockResourceManager.get = mock(() => Promise.resolve({ contents: [] })); + + expect(context.readResource("non-existent://uri")).rejects.toThrow( + "Resource not found", + ); + }); + + test("log methods send correct logging messages", () => { + context.debug("Debug message"); + context.info("Info message"); + context.warning("Warning message"); + context.error("Error message"); + + expect(mockServer.sendLoggingMessage).toHaveBeenCalledTimes(4); + expect(mockServer.sendLoggingMessage).toHaveBeenCalledWith({ + level: "debug", + data: "Debug message", + }); + expect(mockServer.sendLoggingMessage).toHaveBeenCalledWith({ + level: "info", + data: "Info message", + }); + expect(mockServer.sendLoggingMessage).toHaveBeenCalledWith({ + level: "warning", + data: "Warning message", + }); + expect(mockServer.sendLoggingMessage).toHaveBeenCalledWith({ + level: "error", + data: "Error message", + }); + }); + + test("meta property returns correct metadata", () => { + expect(context.meta).toEqual(mockMeta); + }); +}); + +describe("EasyMCP with Context", () => { + let easyMCP: EasyMCP; + + beforeEach(() => { + easyMCP = EasyMCP.create("TestMCP", { version: "1.0.0" }); + }); + + test("Context methods are callable from tool", async () => { + let logCalled = false; + let progressReported = false; + + easyMCP.tool({ + name: "testTool", + fn: async (args: any, context: Context) => { + context.info("Test log"); + logCalled = true; + await context.reportProgress(50, 100); + progressReported = true; + return "Test result"; + }, + }); + + await easyMCP.toolManager.call( + "testTool", + {}, + new Context( + { + sendLoggingMessage: () => {}, + notification: () => Promise.resolve(), + } as unknown as Server, + {} as ResourceManager, + { progressToken: "test-token" }, + ), + ); + + expect(logCalled).toBe(true); + expect(progressReported).toBe(true); + }); +}); diff --git a/lib/Context.ts b/lib/Context.ts new file mode 100644 index 0000000..abe79cf --- /dev/null +++ b/lib/Context.ts @@ -0,0 +1,90 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { + LoggingLevel, + ProgressNotification, + ProgressToken, +} from "@modelcontextprotocol/sdk/types.js"; +import ResourceManager from "./ResourceManager"; +import type { CallToolMeta } from "../types"; + +export class Context { + private server: Server; + private resourceManager: ResourceManager; + private _progressToken?: ProgressToken; + private _meta: CallToolMeta; + + constructor( + server: Server, + resourceManager: ResourceManager, + meta: CallToolMeta, + ) { + this.server = server; + this.resourceManager = resourceManager; + this._progressToken = meta.progressToken; + this._meta = meta; + } + + async reportProgress(progress: number, total?: number): Promise { + if (this._progressToken) { + const notification: ProgressNotification = { + method: "notifications/progress", + params: { + progressToken: this._progressToken, + progress, + total, + }, + }; + await this.server.notification(notification); + } + } + + async readResource(uri: string): Promise { + const result = await this.resourceManager.get(uri); + if (result.contents.length === 0) { + throw new Error(`Resource not found: ${uri}`); + } + const content = result.contents[0]; + if ("text" in content) { + return content.text as string; + } else if ("blob" in content) { + // This line converts a base64-encoded string to a Uint8Array + // 1. atob(content.blob) decodes the base64 string to a regular string + // 2. Uint8Array.from() creates a new Uint8Array from this string + // 3. The mapping function (c) => c.charCodeAt(0) converts each character to its UTF-16 code unit + return Uint8Array.from(atob(content.blob), (c) => c.charCodeAt(0)); + } + throw new Error(`Unsupported resource content type for ${uri}`); + } + + get progressToken(): ProgressToken | undefined { + return this._progressToken; + } + + get meta(): CallToolMeta { + return this._meta; + } + + log(level: LoggingLevel, message: string, loggerName?: string): void { + this.server.sendLoggingMessage({ + level, + data: message, + logger: loggerName, + }); + } + + debug(message: string, loggerName?: string): void { + this.log("debug", message, loggerName); + } + + info(message: string, loggerName?: string): void { + this.log("info", message, loggerName); + } + + warning(message: string, loggerName?: string): void { + this.log("warning", message, loggerName); + } + + error(message: string, loggerName?: string): void { + this.log("error", message, loggerName); + } +} diff --git a/lib/EasyMCP.test.ts b/lib/EasyMCP.test.ts index 2322294..c72cfa8 100644 --- a/lib/EasyMCP.test.ts +++ b/lib/EasyMCP.test.ts @@ -9,7 +9,7 @@ describe("EasyMCP", () => { }); test("create() should return a new instance of EasyMCP", () => { - expect(easyMCP).toBeInstanceOf(EasyMCP); + expect(easyMCP.name).toBe("TestServer"); }); test("registerCapabilities() should return correct capabilities", () => { diff --git a/lib/EasyMCP.ts b/lib/EasyMCP.ts index de87371..3d8dc21 100644 --- a/lib/EasyMCP.ts +++ b/lib/EasyMCP.ts @@ -1,10 +1,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { + CallToolMeta, PromptConfig, ResourceConfig, ResourceTemplateConfig, ServerOptions, ToolConfig, + Version, } from "../types"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import ResourceManager, { @@ -31,12 +33,16 @@ import { type ReadResourceResult, type Root, type ServerCapabilities, + type LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; import ToolManager from "./ToolManager"; import PromptManager from "./PromptManager"; +import { Context } from "./Context"; import RootsManager from "./RootsManager"; +import { metadataKey } from "./experimental/MagicConfig"; +import LogFormatter from "./LogFormatter"; -class EasyMCP { +class BaseMCP { name: string; opts: ServerOptions; resourceManager: ResourceManager; @@ -45,7 +51,7 @@ class EasyMCP { rootsManager: RootsManager; server: Server | null = null; - private constructor(name: string, opts: ServerOptions) { + constructor(name: string, opts: ServerOptions) { this.name = name; this.opts = opts; this.resourceManager = ResourceManager.create(); @@ -54,6 +60,16 @@ class EasyMCP { this.rootsManager = RootsManager.create(); } + listCapabilities() { + return { + resources: this.resourceManager.listResources(), + resourceTemplates: this.resourceManager.listTemplates(), + tools: this.toolManager.list(), + prompts: this.promptManager.list(), + roots: this.rootsManager.list(), + }; + } + registerCapabilities() { const capabilities: ServerCapabilities = {}; @@ -85,7 +101,6 @@ class EasyMCP { async serve() { try { - console.log(`Starting server ${this.name} with options:`, this.opts); const transport = new StdioServerTransport(); this.server = new Server( { @@ -96,7 +111,6 @@ class EasyMCP { ); await this.registerCoreHandlers(); await this.server.connect(transport); - console.log("Server started"); } catch (e) { console.error("Error starting server", e); process.exit(1); @@ -135,122 +149,215 @@ class EasyMCP { return this.rootsManager.add(config); } + async createContext(meta: CallToolMeta): Promise { + if (!this.server) { + throw new Error("Server not initialized. Call serve() first."); + } + return new Context(this.server, this.resourceManager, meta); + } + private async registerCoreHandlers() { if (!this.server) { throw new Error("Server not initialized. Call serve() first."); } - // Resources - this.server.setRequestHandler( - ListResourcesRequestSchema, - async (): Promise => { - return { resources: this.resourceManager.listResources() }; - }, - ); - console.log("Registered ListResources endpoint"); - - this.server.setRequestHandler( - ListResourceTemplatesRequestSchema, - async (): Promise => { - return { resourceTemplates: this.resourceManager.listTemplates() }; - }, - ); - - this.server.setRequestHandler( - ReadResourceRequestSchema, - async (request: ReadResourceRequest): Promise => { - try { - const resourceResult = await this.resourceManager.get( - request.params.uri, - ); - return resourceResult; - } catch (e) { - if (e instanceof ResourceNotFoundError) { - return { - contents: [ - { - uri: request.params.uri, - mimeType: "text/plain", - text: "Resource not found", - }, - ], - }; + if (this.server.getClientCapabilities()?.resources) { + // Resources + this.server.setRequestHandler( + ListResourcesRequestSchema, + async (): Promise => { + return { resources: this.resourceManager.listResources() }; + }, + ); + console.log("Registered ListResources endpoint"); + + this.server.setRequestHandler( + ListResourceTemplatesRequestSchema, + async (): Promise => { + return { resourceTemplates: this.resourceManager.listTemplates() }; + }, + ); + + this.server.setRequestHandler( + ReadResourceRequestSchema, + async (request: ReadResourceRequest): Promise => { + try { + const resourceResult = await this.resourceManager.get( + request.params.uri, + ); + return resourceResult; + } catch (e) { + if (e instanceof ResourceNotFoundError) { + return { + contents: [ + { + uri: request.params.uri, + mimeType: "text/plain", + text: "Resource not found", + }, + ], + }; + } + throw new ResourceError((e as unknown as Error).message); } - throw new ResourceError((e as unknown as Error).message); - } - }, - ); - console.log("Registered ReadResource endpoint"); - - // Tools - this.server.setRequestHandler( - ListToolsRequestSchema, - async (): Promise => { - return { tools: this.toolManager.list() }; - }, - ); - console.log("Registered ListTools endpoint"); - - this.server.setRequestHandler( - CallToolRequestSchema, - async ({ params }): Promise => { - const result = await this.toolManager.call( - params.name, - params.arguments, - ); - return { - content: [ - { - type: "text", - text: result, - }, - ], - }; - }, - ); - console.log("Registered CallTool endpoint"); - - // Prompts - this.server.setRequestHandler( - ListPromptsRequestSchema, - async (): Promise => { - return { prompts: this.promptManager.list() }; - }, - ); - console.log("Registered ListPrompts endpoint"); - - this.server.setRequestHandler( - GetPromptRequestSchema, - async ({ params }): Promise => { - const result = await this.promptManager.call( - params.name, - params.arguments, - ); - return { - messages: [ - { - role: "user", - content: { type: "text", text: result }, - }, - ], - }; - }, - ); - console.log("Registered GetPrompt endpoint"); - - // Roots - this.server.setRequestHandler( - ListRootsRequestSchema, - async (): Promise => { - return { roots: this.rootsManager.list() }; - }, - ); - console.log("Registered ListRoots endpoint"); + }, + ); + console.log("Registered ReadResource endpoint"); + } + + if (this.server.getClientCapabilities()?.tools) { + // Tools + this.server.setRequestHandler( + ListToolsRequestSchema, + async (): Promise => { + return { tools: this.toolManager.list() }; + }, + ); + console.log("Registered ListTools endpoint"); + + this.server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + const progressToken = request.params._meta?.progressToken; + const context = await this.createContext(request.params._meta); + const result = await this.toolManager.call( + request.params.name, + request.params.arguments, + context, + ); + return { + content: [ + { + type: "text", + text: result, + }, + ], + }; + }, + ); + console.log("Registered CallTool endpoint"); + } + + if (this.server.getClientCapabilities()?.prompts) { + // Prompts + this.server.setRequestHandler( + ListPromptsRequestSchema, + async (): Promise => { + return { prompts: this.promptManager.list() }; + }, + ); + console.log("Registered ListPrompts endpoint"); + + this.server.setRequestHandler( + GetPromptRequestSchema, + async ({ params }): Promise => { + const result = await this.promptManager.call( + params.name, + params.arguments, + ); + return { + messages: [ + { + role: "user", + content: { type: "text", text: result }, + }, + ], + }; + }, + ); + console.log("Registered GetPrompt endpoint"); + } + + if (this.server.getClientCapabilities()?.roots) { + // Roots + this.server.setRequestHandler( + ListRootsRequestSchema, + async (): Promise => { + return { roots: this.rootsManager.list() }; + }, + ); + console.log("Registered ListRoots endpoint"); + } + } + + sendLog({ level, message }: { level: LoggingLevel; message: string }) { + if (!this.server) { + throw new Error("Server not initialized. Call serve() first."); + } + + this.server.sendLoggingMessage({ + level, + message: LogFormatter.format(level, message), + }); } static create(name: string, opts: ServerOptions) { - return new EasyMCP(name, opts); + return new BaseMCP(name, opts); } } -export default EasyMCP; +export default class EasyMCP extends BaseMCP { + constructor({ + version, + description, + }: { + version: Version; + description?: string; + }) { + // This call should initialize all the managers + super("", { version, description }); + this.name = this.constructor.name; + + // Handle class-level Root decorators + const rootConfigs = (this.constructor as any).rootConfigs; + if (rootConfigs && Array.isArray(rootConfigs)) { + rootConfigs.forEach((rootConfig) => { + this.root(rootConfig); + }); + } + + const childMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)) + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + .filter((method) => typeof this[method] === "function") + .filter( + (method) => + !Object.getOwnPropertyNames( + Object.getPrototypeOf(BaseMCP.prototype), + ).includes(method), + ); + + childMethods.forEach((method) => { + // Assuming the decorator has been run to wrap these functions, we should have one of these configs on the relevant method. + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + if (this[method][metadataKey].toolConfig) { + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + this.tool(this[method][metadataKey].toolConfig); + } + + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + if (this[method][metadataKey].promptConfig) { + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + this.prompt(this[method][metadataKey].promptConfig); + } + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + if (this[method][metadataKey].rootConfig) { + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + this.root(this[method][metadataKey].rootConfig); + } + + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + if (this[method][metadataKey].resourceConfig) { + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + this.resource(this[method][metadataKey].resourceConfig); + } + + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + if (this[method][metadataKey].resourceTemplateConfig) { + // @ts-expect-error Due to decorator behavior we're doing some JS prototype hacking that triggers a TS error here + this.template(this[method][metadataKey].resourceTemplateConfig); + } + }); + + this.serve(); + } +} diff --git a/lib/InputGenerator.test.ts b/lib/InputGenerator.test.ts deleted file mode 100644 index e8dc96b..0000000 --- a/lib/InputGenerator.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { parseFunctionSignature, CouldNotParseError } from "./InputGenerator"; -import ts from "typescript"; - -describe("parseFunctionSignature", () => { - function createNode(code: string): ts.Node { - const sourceFile = ts.createSourceFile( - "test.ts", - code, - ts.ScriptTarget.Latest, - true, - ); - return sourceFile.statements[0]; - } - - test("should parse a simple function declaration", () => { - const node = createNode("function simpleFunc() {}"); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "simpleFunc", - description: "", - parameters: [], - }); - }); - - test("should parse a function with one parameter", () => { - const node = createNode("function oneParam(x: number) {}"); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "oneParam", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: true }, - ], - }); - }); - - test("should parse a function with multiple parameters", () => { - const node = createNode( - "function multiParams(x: number, y: string, z: boolean) {}", - ); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "multiParams", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: true }, - { name: "y", type: "string", description: "", required: true }, - { name: "z", type: "string", description: "", required: true }, // boolean is mapped to string - ], - }); - }); - - test("should parse an arrow function", () => { - const node = createNode("const arrowFunc = (x: number, y: string) => {}"); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "arrowFunc", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: true }, - { name: "y", type: "string", description: "", required: true }, - ], - }); - }); - - test("should throw CouldNotParseError for non-function nodes", () => { - const node = createNode("const x = 5;"); - expect(() => parseFunctionSignature(node)).toThrow(CouldNotParseError); - }); - - test("should parse a function with complex types", () => { - const node = createNode( - "function complexTypes(a: Array, b: { x: number, y: string }, c: number) {}", - ); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "complexTypes", - description: "", - parameters: [ - { name: "a", type: "array", description: "", required: true }, - { name: "b", type: "object", description: "", required: true }, - { name: "c", type: "number", description: "", required: true }, - ], - }); - }); - - test("should parse a function with optional parameters", () => { - const node = createNode( - "function optionalParams(x: number, y?: string) {}", - ); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "optionalParams", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: true }, - { name: "y", type: "string", description: "", required: false }, - ], - }); - }); - - test("should parse a function with default parameters", () => { - const node = createNode( - "function defaultParams(x: number = 0, y: string = 'default') {}", - ); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "defaultParams", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: false }, - { name: "y", type: "string", description: "", required: false }, - ], - }); - }); - - test("should parse JSDoc comments for function parameters", () => { - const node = createNode(` - /** - * @param x The first number - * @param y The second string - */ - function withJSDoc(x: number, y: string) {} - `); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "withJSDoc", - description: "", - parameters: [ - { - name: "x", - type: "number", - description: "The first number", - required: true, - }, - { - name: "y", - type: "string", - description: "The second string", - required: true, - }, - ], - }); - }); - - test("should parse JSDoc comments for arrow function parameters", () => { - const node = createNode(` - /** - * @param a An array of strings - * @param b An object with properties - */ - const arrowWithJSDoc = (a: string[], b: { prop: number }) => {} - `); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "arrowWithJSDoc", - description: "", - parameters: [ - { - name: "a", - type: "array", - description: "An array of strings", - required: true, - }, - { - name: "b", - type: "object", - description: "An object with properties", - required: true, - }, - ], - }); - }); - - test("should handle functions with partial JSDoc comments", () => { - const node = createNode(` - /** - * @param x The first number - */ - function partialJSDoc(x: number, y: string) {} - `); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "partialJSDoc", - description: "", - parameters: [ - { - name: "x", - type: "number", - description: "The first number", - required: true, - }, - { name: "y", type: "string", description: "", required: true }, - ], - }); - }); - - test("should parse function description and parameter comments", () => { - const node = createNode(` - /** - * This function adds two numbers. - * @param x The first number - * @param y The second number - */ - function add(x: number, y: number) {} - `); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "add", - description: "This function adds two numbers.", - parameters: [ - { - name: "x", - type: "number", - description: "The first number", - required: true, - }, - { - name: "y", - type: "number", - description: "The second number", - required: true, - }, - ], - }); - }); - - test("should parse function description for arrow functions", () => { - const node = createNode(` - /** - * This arrow function multiplies two numbers. - * @param a The first factor - * @param b The second factor - */ - const multiply = (a: number, b: number) => {} - `); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "multiply", - description: "This arrow function multiplies two numbers.", - parameters: [ - { - name: "a", - type: "number", - description: "The first factor", - required: true, - }, - { - name: "b", - type: "number", - description: "The second factor", - required: true, - }, - ], - }); - }); - - test("should handle functions with no JSDoc comment", () => { - const node = createNode(`function noJSDoc(x: number, y: string) {}`); - const result = parseFunctionSignature(node); - expect(result).toEqual({ - name: "noJSDoc", - description: "", - parameters: [ - { name: "x", type: "number", description: "", required: true }, - { name: "y", type: "string", description: "", required: true }, - ], - }); - }); -}); diff --git a/lib/InputGenerator.ts b/lib/InputGenerator.ts deleted file mode 100644 index 0aaaff7..0000000 --- a/lib/InputGenerator.ts +++ /dev/null @@ -1,89 +0,0 @@ -import ts, { type NodeArray } from "typescript"; -import type { ToolArg } from "../types"; - -export class CouldNotParseError extends Error { - message = "Could not parse function declaration"; -} - -export function parseFunctionSignature(node: ts.Node) { - let name: string | undefined; - let parameters: NodeArray | undefined; - let jsDocComment: ts.JSDoc | undefined; - - if (ts.isFunctionDeclaration(node)) { - name = node.name?.getText(); - parameters = node.parameters; - jsDocComment = node.jsDoc?.[0]; - } else if (ts.isVariableStatement(node)) { - const declaration = node.declarationList.declarations[0]; - if ( - declaration && - ts.isVariableDeclaration(declaration) && - declaration.initializer && - ts.isArrowFunction(declaration.initializer) - ) { - name = declaration.name.getText(); - parameters = declaration.initializer.parameters; - jsDocComment = (node as ts.VariableStatement).jsDoc?.[0]; - } - } - - if (!parameters) throw new CouldNotParseError(); - - const { functionDescription, paramDescriptions } = - extractJSDocInfo(jsDocComment); - - return { - name, - description: functionDescription, - parameters: parameters.map((param): ToolArg => { - const paramType = param.type ? param.type.getText() : "any"; - const paramName = param.name.getText(); - return { - name: paramName, - type: mapTypeToToolArgType(paramType), - description: paramDescriptions[paramName] || "", - required: !param.questionToken && !param.initializer, - }; - }), - }; -} - -function extractJSDocInfo(jsDoc: ts.JSDoc | undefined): { - functionDescription: string; - paramDescriptions: Record; -} { - const paramDescriptions: Record = {}; - let functionDescription = ""; - - if (jsDoc) { - // Extract function description - if (jsDoc.comment) { - functionDescription = - typeof jsDoc.comment === "string" - ? jsDoc.comment - : jsDoc.comment.map((c) => c.text).join(" "); - } - - // Extract parameter descriptions - jsDoc.tags?.forEach((tag) => { - if (ts.isJSDocParameterTag(tag) && tag.name && tag.comment) { - paramDescriptions[tag.name.getText()] = - typeof tag.comment === "string" - ? tag.comment - : tag.comment.map((c) => c.text).join(" "); - } - }); - } - - return { functionDescription, paramDescriptions }; -} - -function mapTypeToToolArgType(type: string): ToolArg["type"] { - // Complex types must be checked first for cases where primitive types are wrapped in other type identifiers such as: Array - if (type.includes("Array") || type.includes("[]")) return "array"; - if (type.includes("{") || type.includes("object")) return "object"; - if (type.includes("string")) return "string"; - if (type.includes("number")) return "number"; - return "string"; // Default to string for unknown types -} diff --git a/lib/LogFormatter.ts b/lib/LogFormatter.ts new file mode 100644 index 0000000..c068d1d --- /dev/null +++ b/lib/LogFormatter.ts @@ -0,0 +1,65 @@ +import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Prefixes messages with their log level + */ +export default class LogFormatter { + static format(level: LoggingLevel, message: string): string { + switch (level) { + case "debug": + return this.debug(message); + case "info": + return this.info(message); + case "warning": + return this.warning(message); + case "error": + return this.error(message); + case "notice": + return this.notice(message); + case "critical": + return this.critical(message); + case "alert": + return this.alert(message); + case "emergency": + return this.emergency(message); + default: + console.warn( + "Invalid log level passed to LogFormatter. This should never happen.", + level, + ); + return message; + } + } + + private static debug(message: string) { + return "debug: " + message; + } + + private static notice(message: string) { + return "notice: " + message; + } + + private static critical(message: string) { + return "critical: " + message; + } + + private static alert(message: string) { + return "alert: " + message; + } + + private static emergency(message: string) { + return "emergency: " + message; + } + + private static info(message: string) { + return "info: " + message; + } + + private static warning(message: string) { + return "warning: " + message; + } + + private static error(message: string) { + return "error: " + message; + } +} diff --git a/lib/MCPPrompt.ts b/lib/MCPPrompt.ts index 18c007d..7128b13 100644 --- a/lib/MCPPrompt.ts +++ b/lib/MCPPrompt.ts @@ -34,7 +34,6 @@ class MCPPrompt { args: [ { name: faker.lorem.word(), - type: faker.helpers.arrayElement(["string", "number", "boolean"]), description: faker.lorem.sentence(), required: faker.datatype.boolean(), }, diff --git a/lib/MCPTool.ts b/lib/MCPTool.ts index f91f3d3..4d21a1d 100644 --- a/lib/MCPTool.ts +++ b/lib/MCPTool.ts @@ -1,9 +1,15 @@ import { faker } from "@faker-js/faker"; -import type { ToolArg, ToolConfig, ToolDefinition } from "../types"; +import type { + ToolArg, + ToolCallFulfillmentFn, + ToolConfig, + ToolDefinition, +} from "../types"; +import type { Context } from "./Context"; class MCPTool { private _definition: ToolDefinition; - private fn: (...args: any[]) => Promise; + private fn: ToolCallFulfillmentFn; private constructor({ name, description, inputs = [], fn }: ToolConfig) { this._definition = { @@ -35,13 +41,21 @@ class MCPTool { return properties; } - async callFn(...args: any): Promise { - const results = await this.fn(...args); + async callFn( + args?: Record, + context?: Context, + ): Promise { + const results = await this.fn(args, context); return results; } static create(config: ToolConfig) { - return new MCPTool(config); + return new MCPTool({ + name: config.name, + description: config.description, + inputs: config.inputs || [], + fn: config.fn, + }); } static mocked() { diff --git a/lib/ToolManager.ts b/lib/ToolManager.ts index 6cc6b1e..20a9193 100644 --- a/lib/ToolManager.ts +++ b/lib/ToolManager.ts @@ -1,4 +1,5 @@ import MCPTool from "./MCPTool"; +import { Context } from "./Context"; import type { SerializableTool, ToolConfig } from "../types"; export class ToolError extends Error {} @@ -37,14 +38,18 @@ export default class ToolManager { return Object.values(this.tools).map(ToolConverter.toSerializableTool); } - async call(name: string, args?: Record) { + async call( + name: string, + args?: Record, + context?: Context, + ): Promise { const foundTool = this.tools[name]; if (!foundTool) { throw new ToolNotFoundError(); } - const result = await foundTool.callFn(args); + const result = await foundTool.callFn(args, context); return result; } diff --git a/lib/experimental/Decorators.test.ts b/lib/experimental/Decorators.test.ts new file mode 100644 index 0000000..15b606a --- /dev/null +++ b/lib/experimental/Decorators.test.ts @@ -0,0 +1,172 @@ +import { expect, test, describe, beforeEach } from "bun:test"; +import EasyMCP from "../EasyMCP"; +import emitter from "events"; +import { Tool } from "./decorators/Tool"; +import { Resource } from "./decorators/Resource"; +import { Prompt } from "./decorators/Prompt"; +import { Root } from "./decorators/Root"; +import { Context } from "../Context"; + +// We'll get memory leak errors if we don't raise this number (just in this test suite) +emitter.setMaxListeners(100); + +@Root("/another-root", { name: "Named Root" }) +@Root("/test-root/photos") +class TestMCP extends EasyMCP { + @Tool() + simpleTool(param1: string, param2: number) { + return `${param1}: ${param2}`; + } + + @Tool({ + description: "A tool with optional parameters", + optionals: ["optionalParam"], + }) + toolWithOptionals(requiredParam: string, optionalParam?: number) { + return `${requiredParam}: ${optionalParam || "not provided"}`; + } + + @Resource("test://simple-resource") + simpleResource() { + return "Simple resource content"; + } + + @Resource("test://resource/{param}") + resourceWithParam(param: string) { + return `Resource content with param: ${param}`; + } + + @Prompt() + simplePrompt(name: string) { + return `Hello, ${name}!`; + } + + @Prompt({ + name: "customPrompt", + description: "A custom prompt with multiple parameters", + args: [ + { name: "name", description: "User's name", required: true }, + { name: "age", description: "User's age", required: false }, + ], + }) + customPrompt(name: string, age?: number) { + return `Hello, ${name}${age ? ` (${age} years old)` : ""}!`; + } +} + +describe("Decorator Functionality", () => { + let mcp: TestMCP; + + beforeEach(() => { + mcp = new TestMCP({ version: "1.0.0" }); + }); + + describe("@Root Decorator", () => { + test("should register roots correctly", () => { + const roots = mcp.rootsManager.list(); + // expect(roots).toHaveLength(2); + expect(roots[0]).toEqual({ + uri: "/test-root/photos", + name: "testRootPhotos", + }); + expect(roots[1]).toEqual({ uri: "/another-root", name: "Named Root" }); + }); + }); + + describe("@Tool Decorator", () => { + test("should register simple tool correctly", () => { + const tools = mcp.toolManager.list(); + const simpleTool = tools.find((t) => t.name === "simpleTool"); + expect(simpleTool).toBeDefined(); + expect(simpleTool!.inputSchema.properties).toHaveProperty("param1"); + expect(simpleTool!.inputSchema.properties).toHaveProperty("param2"); + }); + + test("should register tool with optional parameters correctly", () => { + const tools = mcp.toolManager.list(); + const toolWithOptionals = tools.find( + (t) => t.name === "toolWithOptionals", + ); + expect(toolWithOptionals).toBeDefined(); + expect(toolWithOptionals!.description).toBe( + "A tool with optional parameters", + ); + expect(toolWithOptionals!.inputSchema.required).not.toContain( + "optionalParam", + ); + }); + + test("should be able to call decorated tool", async () => { + const result = await mcp.toolManager.call( + "simpleTool", + { param1: "test", param2: 42 }, + {} as Context, + ); + expect(result).toBe("test: 42"); + }); + }); + + describe("@Resource Decorator", () => { + test("should register simple resource correctly", () => { + const resources = mcp.resourceManager.listResources(); + const simpleResource = resources.find( + (r) => r.uri === "test://simple-resource", + ); + expect(simpleResource).toBeDefined(); + }); + + test("should register resource with parameters correctly", () => { + const templates = mcp.resourceManager.listTemplates(); + const resourceWithParam = templates.find( + (t) => t.uriTemplate === "test://resource/{param}", + ); + expect(resourceWithParam).toBeDefined(); + }); + + test("should be able to get content from decorated resource", async () => { + const result = await mcp.resourceManager.get("test://simple-resource"); + expect(result.contents[0].text).toBe("Simple resource content"); + }); + + test("should be able to get content from decorated resource with parameter", async () => { + const result = await mcp.resourceManager.get("test://resource/testParam"); + expect(result.contents[0].text).toBe( + "Resource content with param: testParam", + ); + }); + }); + + describe("@Prompt Decorator", () => { + test("should register simple prompt correctly", () => { + const prompts = mcp.promptManager.list(); + const simplePrompt = prompts.find((p) => p.name === "simplePrompt"); + expect(simplePrompt).toBeDefined(); + expect(simplePrompt!.args).toHaveLength(1); + }); + + test("should register custom prompt correctly", () => { + const prompts = mcp.promptManager.list(); + const customPrompt = prompts.find((p) => p.name === "customPrompt"); + expect(customPrompt).toBeDefined(); + expect(customPrompt!.description).toBe( + "A custom prompt with multiple parameters", + ); + expect(customPrompt!.args).toHaveLength(2); + }); + + test("should be able to call decorated prompt", async () => { + const result = await mcp.promptManager.call("simplePrompt", { + name: "Test", + }); + expect(result).toBe("Hello, Test!"); + }); + + test("should be able to call custom decorated prompt", async () => { + const result = await mcp.promptManager.call("customPrompt", { + name: "Test", + age: 30, + }); + expect(result).toBe("Hello, Test (30 years old)!"); + }); + }); +}); diff --git a/lib/experimental/MagicConfig.test.ts b/lib/experimental/MagicConfig.test.ts new file mode 100644 index 0000000..4a1a969 --- /dev/null +++ b/lib/experimental/MagicConfig.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, spyOn, test, beforeEach } from "bun:test"; +import { Tool } from "./decorators/Tool"; +import ToolManager from "../ToolManager"; +import EasyMCP from "../EasyMCP"; + +class TestMCP extends EasyMCP { + @Tool({ + description: "A function with various parameter types", + optionals: ["active", "items", "age"], + }) + exampleFunc(name: string, active?: string, items?: string[], age?: number) { + return `exampleFunc called: ${name}, ${active}, ${items}, ${age}`; + } +} + +describe("Tool Decorator", () => { + let mcp: TestMCP; + + beforeEach(() => { + mcp = new TestMCP({ version: "1.0.0" }); + }); + + test("Tool is added to ToolManager upon class instantiation", () => { + const tools = mcp.toolManager.list(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("exampleFunc"); + }); + + test("Calling the method executes the original function", () => { + const result = mcp.exampleFunc("John", "yes", ["item1", "item2"], 30); + expect(result).toBe("exampleFunc called: John, yes, item1,item2, 30"); + }); + + test("Metadata is correctly captured", () => { + const tools = mcp.toolManager.list(); + const exampleTool = tools[0]; + + expect(exampleTool.name).toBe("exampleFunc"); + expect(exampleTool.description).toBe( + "A function with various parameter types", + ); + expect(Object.values(exampleTool.inputSchema.properties)).toHaveLength(4); + + expect(exampleTool.inputSchema.properties["name"].type).toBe("string"); + expect(exampleTool.inputSchema.properties["name"].description).toBe( + "a param named name of type string", + ); + + expect(exampleTool.inputSchema.properties["active"].type).toBe("string"); + expect(exampleTool.inputSchema.properties["active"].description).toBe( + "a param named active of type string", + ); + + expect(exampleTool.inputSchema.properties["items"].type).toBe("array"); + expect(exampleTool.inputSchema.properties["items"].description).toBe( + "a param named items of type array", + ); + + expect(exampleTool.inputSchema.properties["age"].type).toBe("number"); + expect(exampleTool.inputSchema.properties["age"].description).toBe( + "a param named age of type number", + ); + }); + + test("ToolManager.add is called immediately when decorator is applied", () => { + const addSpy = spyOn(ToolManager.prototype, "add"); + + class SpyTestMCP extends EasyMCP { + @Tool() + spyMethod() {} + } + + new SpyTestMCP({ version: "1.0.0" }); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: "spyMethod", + }), + ); + + addSpy.mockRestore(); + }); +}); diff --git a/lib/experimental/MagicConfig.ts b/lib/experimental/MagicConfig.ts new file mode 100644 index 0000000..018f3fb --- /dev/null +++ b/lib/experimental/MagicConfig.ts @@ -0,0 +1,46 @@ +import "reflect-metadata"; +import type { FunctionConfig, ParameterMetadata } from "../../types"; + +export const metadataKey = Symbol("functionMetadata"); + +function getParameterNames(func: Function): (string | null)[] { + const funcString = func.toString(); + const parameterList = funcString + .slice(funcString.indexOf("(") + 1, funcString.indexOf(")")) + .match(/([a-zA-Z0-9_$]+)/g); + + return parameterList || []; +} + +export function extractFunctionMetadata( + target: any, + propertyKey: string | symbol, + config: FunctionConfig, +) { + const parameterTypes: any[] = + Reflect.getMetadata("design:paramtypes", target, propertyKey) || []; + const parameterNames = getParameterNames(target[propertyKey]); + + const parameters: ParameterMetadata[] = parameterTypes.map((type, index) => { + const paramName = parameterNames[index] || `arg${index}`; + const isOptional = config.optionals + ? config.optionals.includes(paramName) + : false; + + return { + name: paramName, + type: type.name.toLowerCase(), + optional: isOptional, + }; + }); + + const metadata = { + ...config, + parameters, + name: propertyKey.toString(), + }; + + Reflect.defineMetadata(metadataKey, metadata, target, propertyKey); + + return metadata; +} diff --git a/lib/experimental/decorators/Prompt.ts b/lib/experimental/decorators/Prompt.ts new file mode 100644 index 0000000..6955f00 --- /dev/null +++ b/lib/experimental/decorators/Prompt.ts @@ -0,0 +1,53 @@ +import type { PromptConfig, PromptDefinition } from "../../../types"; +import { extractFunctionMetadata, metadataKey } from "../MagicConfig"; + +export function Prompt( + config?: PromptDefinition, +): PropertyDecorator & MethodDecorator { + return function (...args: any[]) { + const [target, propertyKey, descriptor] = args; + + // If it's not a method decorator, return + if (args.length < 2) { + return; + } + + const originalMethod = descriptor.value; + + const metadata = extractFunctionMetadata(target, propertyKey, config || {}); + + // If overrides exist, then use them. Otherwise infer. + const inputParamsSource = config?.args ? config.args : metadata.parameters; + + const promptConfig: PromptConfig = { + name: config?.name || metadata.name, + description: config?.description || metadata.description || `a prompt`, + args: inputParamsSource.map((param) => ({ + name: param.name, + // In the MCP TS SDK prompt arguments do not have a type, so we don't worry about it here! + description: "", // You might want to add a way to specify parameter descriptions + required: !param.optional, + })), + fn: (argsObject: any) => { + if (argsObject) { + return originalMethod(...Object.values(argsObject)); + } + return originalMethod(); + }, + }; + + /** + We add the Prompt configuration to the original method so that it lives on the functions prototype. + + When we instantiate the class later, we have access to this config which we can then use to register the Prompt with the Prompt Manager. + */ + + if (!originalMethod[metadataKey]) { + originalMethod[metadataKey] = {}; + } + + originalMethod[metadataKey].promptConfig = promptConfig; + + return descriptor; + }; +} diff --git a/lib/experimental/decorators/Resource.ts b/lib/experimental/decorators/Resource.ts new file mode 100644 index 0000000..27e7b36 --- /dev/null +++ b/lib/experimental/decorators/Resource.ts @@ -0,0 +1,85 @@ +import { extractFunctionMetadata, metadataKey } from "../MagicConfig"; +import type { + ResourceConfig, + ResourceDefinition, + ResourceTemplateConfig, +} from "../../../types"; + +export function Resource( + uriOrUriTemplate: string, + config: Partial = {}, +) { + return function (...args: any[]) { + const [target, propertyKey, descriptor] = args; + + // If it's not a method decorator, return + if (args.length < 2) { + return; + } + + const originalMethod = descriptor.value; + + const metadata = extractFunctionMetadata(target, propertyKey, config); + + let resourceConfig: ResourceConfig | null = null; + let templateConfig: ResourceTemplateConfig | null = null; + + // Check whether the uri is a uri or a uriTemplate + if (uriOrUriTemplate.includes("{")) { + // Create a ResourceTemplateConfig object + templateConfig = { + uriTemplate: uriOrUriTemplate, + name: config.name || metadata.name, + description: + config.description || + metadata.description || + `a resource with name ${metadata.name} at uri ${uriOrUriTemplate}`, + mimeType: config.mimeType || ("text/plain" as const), + // MCP passes in an arguments OBJECT to the function, so we need to convert that back to the parameters the function expects. + fn: (argsObject) => { + if (argsObject) { + return originalMethod(...Object.values(argsObject)); + } + return originalMethod(); + }, + }; + } else { + resourceConfig = { + uri: uriOrUriTemplate, + name: config.name || metadata.name, + description: + config.description || + metadata.description || + `a resource with name ${metadata.name} at uri ${uriOrUriTemplate}`, + mimeType: config.mimeType || ("text/plain" as const), + // MCP passes in an arguments OBJECT to the function, so we need to convert that back to the parameters the function expects. + fn: (argsObject) => { + if (argsObject) { + return originalMethod(...Object.values(argsObject)); + } + return originalMethod(); + }, + }; + } + + /** + We add the Resource configuration to the original method so that it lives on the functions prototype. + + When we instantiate the class later, we have access to this config which we can then use to register the Resource with the Resource Manager. + */ + + if (!originalMethod[metadataKey]) { + originalMethod[metadataKey] = {}; + } + + if (resourceConfig) { + originalMethod[metadataKey].resourceConfig = resourceConfig; + } + + if (templateConfig) { + originalMethod[metadataKey].resourceTemplateConfig = templateConfig; + } + + return descriptor; + }; +} diff --git a/lib/experimental/decorators/Root.ts b/lib/experimental/decorators/Root.ts new file mode 100644 index 0000000..0cd7350 --- /dev/null +++ b/lib/experimental/decorators/Root.ts @@ -0,0 +1,26 @@ +import camelCase from "lodash.camelcase"; + +/** + * All other decorators wrap instance methods. This decorator wraps the class itself because Roots do not have logic or function params when they're fulfilled. + */ +export function Root( + uri: string, + config: { name?: string } = { name: undefined }, +) { + return function (constructor: T) { + if (!constructor.hasOwnProperty("rootConfigs")) { + Object.defineProperty(constructor, "rootConfigs", { + value: [], + writable: true, + configurable: true, + }); + } + + (constructor as any).rootConfigs.push({ + uri, + name: config.name || camelCase(uri), + }); + + return constructor; + }; +} diff --git a/lib/experimental/decorators/Tool.ts b/lib/experimental/decorators/Tool.ts new file mode 100644 index 0000000..d424206 --- /dev/null +++ b/lib/experimental/decorators/Tool.ts @@ -0,0 +1,125 @@ +import type { FunctionConfig, ToolConfig } from "../../../types"; +import type { Context } from "../../Context"; +import { extractFunctionMetadata, metadataKey } from "../MagicConfig"; + +export function Tool(config?: FunctionConfig) { + return function (...args: any[]) { + const [target, propertyKey, descriptor] = args; + + // If it's not a method decorator, return + if (args.length < 2) { + return; + } + + const originalMethod = descriptor.value; + + const metadata = extractFunctionMetadata(target, propertyKey, config || {}); + + if (!config) { + config = { + name: propertyKey, + description: `A tool that accepts parameters: ${metadata.parameters.map((param) => `${param.name} of type ${param.type}`)}`, + }; + } + + const inputConfigSource = config.parameters + ? config.parameters + : metadata.parameters; + + const toolConfig: ToolConfig = { + name: propertyKey, + description: config.description || "", + inputs: inputConfigSource.map((param) => ({ + name: param.name, + type: param.type, + description: + param.description || + `a param named ${param.name} of type ${param.type}`, + required: !param.optional, + })), + // MCP passes in an arguments OBJECT to the function, so we need to convert that back to the parameters the function expects. + fn: (argsObject: any, context?: Context) => { + if (argsObject) { + return originalMethod.call( + target, + ...Object.values(argsObject), + context, + ); + } + return originalMethod.call(target, context); + }, + }; + + /** + We add the tool configuration to the original method so that it lives on the functions prototype. + + When we instantiate the class later, we have access to this config which we can then use to register the tool with the Tool Manager. + */ + if (!originalMethod[metadataKey]) { + originalMethod[metadataKey] = {}; + } + originalMethod[metadataKey].toolConfig = toolConfig; + + // Return the original descriptor + return descriptor; + }; +} + +// return function ( +// target: any, +// propertyKey: string, +// descriptor: PropertyDescriptor, +// ): PropertyDescriptor | void { +// const originalMethod = descriptor.value; + +// const metadata = extractFunctionMetadata(target, propertyKey, config || {}); + +// if (!config) { +// config = { +// name: propertyKey, +// description: `A tool that accepts parameters: ${metadata.parameters.map((param) => `${param.name} of type ${param.type}`)}`, +// }; +// } + +// const inputConfigSource = config.parameters +// ? config.parameters +// : metadata.parameters; + +// const toolConfig: ToolConfig = { +// name: propertyKey, +// description: config.description || "", +// inputs: inputConfigSource.map((param) => ({ +// name: param.name, +// type: param.type, +// description: +// param.description || +// `a param named ${param.name} of type ${param.type}`, +// required: !param.optional, +// })), +// // MCP passes in an arguments OBJECT to the function, so we need to convert that back to the parameters the function expects. +// fn: (argsObject: any, context?: Context) => { +// if (argsObject) { +// return originalMethod.call( +// target, +// ...Object.values(argsObject), +// context, +// ); +// } +// return originalMethod.call(target, context); +// }, +// }; + +// /** +// We add the tool configuration to the original method so that it lives on the functions prototype. + +// When we instantiate the class later, we have access to this config which we can then use to register the tool with the Tool Manager. +// */ +// if (!originalMethod[metadataKey]) { +// originalMethod[metadataKey] = {}; +// } +// originalMethod[metadataKey].toolConfig = toolConfig; + +// // The function itself remains unchanged, except for the metadata we attached to it. +// return descriptor; +// }; +// } diff --git a/package.json b/package.json index 5891c6d..e640bc8 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,56 @@ { "name": "easy-mcp", "module": "index.ts", + "version": "0.0.0-development", "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "The simplest way to create Model Context Protocol (MCP) servers in TypeScript.", + "repository": { + "type": "git", + "url": "https://github.com/zcaceres/easy-mcp.git" + }, + "homepage": "https://github.com/zcaceres/easy-mcp#readme", + "author": "Zach Caceres (zach.dev)", + "license": "MIT", + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "typescript", + "server" + ], "scripts": { "start": "bun run index.ts", - "dev": "bun --watch index.ts" + "start:decorators": "bun run examples/example-experimental-decorators.ts", + "start:express": "bun run examples/example-express.ts", + "dev": "bun --watch index.ts", + "build": "bun build ./index.ts --outdir ./dist && tsc", + "test": "bun test --tsconfig-override tsconfig.json lib", + "semantic-release": "semantic-release", + "prepublishOnly": "bun run build" }, "devDependencies": { "@anatine/zod-mock": "^3.13.4", "@faker-js/faker": "^9.3.0", - "@types/bun": "latest" + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "@types/bun": "latest", + "@types/lodash.camelcase": "^4.3.9", + "semantic-release": "^24.2.1" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", - "path-to-regexp": "^8.2.0" + "lodash.camelcase": "^4.3.0", + "path-to-regexp": "^8.2.0", + "reflect-metadata": "^0.2.2" } } diff --git a/tsconfig.json b/tsconfig.json index 238655f..3daebf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,21 +7,32 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "noEmit": true, // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) + // Some stricter flags (adjust as needed) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "noPropertyAccessFromIndexSignature": false, + + // Additional options for .d.ts generation + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./", + "noEmit": false, + "emitDeclarationOnly": true + }, + "include": ["index.ts", "lib/**/*.ts"], + "exclude": ["node_modules", "**/*.test.ts", "dist"] } diff --git a/types.d.ts b/types.d.ts index 4ca115e..30cd654 100644 --- a/types.d.ts +++ b/types.d.ts @@ -4,8 +4,11 @@ import type { Tool, } from "@modelcontextprotocol/sdk/types.js"; +export type Version = `${number}.${number}.${number}`; + export type ServerOptions = { - version: `${number}.${number}.${number}`; + version: Version; + description?: string; }; export type ToolInputSchema = { @@ -29,7 +32,11 @@ export type ToolDefinition = { }; }; -export type FulfillmentFn = (...args: any[]) => Promise; +export type FulfillmentFn = (...args: any) => Promise; +export type ToolCallFulfillmentFn = ( + ...args: any, + context: Context, +) => Promise; export type ToolConfig = { name: string; @@ -166,3 +173,30 @@ export type MimeTypes = | "video/3gpp" | "video/3gpp2" | "application/x-7z-compressed"; + +export interface ParameterMetadata { + name: string; + type: "string" | "number" | "object" | "array"; + description?: string; + optional?: boolean; +} + +export interface FunctionConfig { + name?: string; + description?: string; + version?: number; + parameters?: ParameterMetadata[]; + optionals?: string[]; +} + +export interface FunctionMetadata { + name: string; + description: string; + version?: number; + parameters: ParameterMetadata[]; + optionals?: string[]; + [key: string]: any; +} + +export type CallToolParams = z.infer["params"]; +export type CallToolMeta = NonNullable;