diff --git a/.nx/version-plans/version-plan-1751658342657.md b/.nx/version-plans/version-plan-1751658342657.md new file mode 100644 index 000000000..9ba750654 --- /dev/null +++ b/.nx/version-plans/version-plan-1751658342657.md @@ -0,0 +1,7 @@ +--- +app-sdk: minor +mcp-sdk: major +mcp: major +--- + +Migration of MCP building from JSON file exclusively to Registry based with json available for overrides diff --git a/docs/src/Developers/App-Agent-Developers/MCP.md b/docs/src/Developers/App-Agent-Developers/MCP.md index 3ec0adde2..ced57662d 100644 --- a/docs/src/Developers/App-Agent-Developers/MCP.md +++ b/docs/src/Developers/App-Agent-Developers/MCP.md @@ -7,9 +7,9 @@ title: MCP - Model Context Protocol Any Vincent App can be converted into a Model Protocol Server (MCP) that can be consumed by any Large Language Model (LLM) with support for the MCP standard. -We provide an [implementation of an MCP Server](https://github.com/LIT-Protocol/Vincent/tree/main/packages/apps/mcp), connectable through STDIO and HTTP transports. You can use it (forking or [using `npx`](https://www.npmjs.com/package/@lit-protocol/vincent-mcp-server)) with your keys and Vincent Apps or you can customize the whole process to make your own Vincent MCP Server. +We provide an [implementation of an MCP Server](https://github.com/LIT-Protocol/Vincent/tree/main/packages/apps/mcp), connectable through STDIO and HTTP transports. You can use it (forking or [using `npx`](https://www.npmjs.com/package/@lit-protocol/vincent-mcp-server)) with your keys and Vincent Apps or you can customize the whole process to make your own Vincent MCP Server based on our [MCP SDK](https://github.com/LIT-Protocol/Vincent/blob/main/packages/libs/mcp-sdk/README.md). -By following this process, your Vincent App tools will be exposed to LLMs as a set of MCP tools. The MCP server can also be extended with custom tools and prompts to suit your specific needs. +By following this process, your Vincent App tools will be exposed to LLMs as a set of MCP tools. The MCP server can also be extended with custom tools and prompts to suit your specific needs or provide extra capabilities around the Vincent Tool ecosystem. And if you're building an AI application, check out our [OpenAI AgentKit demo](https://github.com/LIT-Protocol/Vincent-MCP-OpenAI-AgentKit) for a guide on how to integrate your Vincent App with the OpenAI AgentKit. @@ -20,6 +20,7 @@ The first step is to convert your Vincent App into an MCP server. This is done b ```typescript import { ethers } from 'ethers'; import { getVincentAppServer, VincentAppDef } from '@lit-protocol/vincent-mcp-sdk'; +import { bundledVincentTool } from '@organization/npm-published-vincent-too'; // Create a signer using your Vincent App delegatee private key const provider = new ethers.providers.JsonRpcProvider( @@ -34,18 +35,17 @@ const appDef: VincentAppDef = { name: 'My Vincent App', description: 'A Vincent application that executes tools for its delegators', tools: { - QmIpfsCid1: { + '@organization/npm-published-vincent-tool': { + version: '1.0.0', + bundledVincentTool: bundledVincentTool, name: 'myTool', description: 'A tool that does something', - parameters: [ - { - name: 'param1', - type: 'string', + parameters: { + param1: { description: 'A parameter that is used in the tool to do something', - optional: true, }, // Add more parameters here - ], + }, }, // Add the other tools in your Vincent App here }, @@ -55,19 +55,17 @@ const appDef: VincentAppDef = { const server = await getVincentAppServer(wallet, appDef); ``` -You can check the [Uniswap Swap example app json](https://github.com/LIT-Protocol/Vincent/blob/main/packages/apps/mcp/vincent-app.example.json) for a complete Vincent App definition. - ## Extending the MCP Server At this moment you can add more tools, resources or prompts to the server. ```typescript -server.tool(...); -server.resource(...); -server.prompt(...); +server.registerTool(/*...*/); +server.registerResource(/*...*/); +server.registerPrompt(/*...*/); ``` -These tools, resources and prompts will be exposed in the server along with the ones from the Vincent App definition. Consider adding any other tools that you want to be executed by the LLM and that are not Vincent Tools such as tools to query balance or fetch useful data from external sources. +These tools, resources and prompts will be exposed in the server along with the ones from the Vincent App definition. Consider adding any other tools that you want to be executed by the LLM and that are not Vincent Tools. For example, you could add tools to query balance or fetch useful data from external sources. ## Picking a Transport @@ -124,9 +122,11 @@ For an already working MCP Server that simply wraps your Vincent App you can che This MCP Server includes: +- Running as `npx` commands directly from NPM +- Automatic tool installation using `npx-import`. No local installation needed - HTTP and STDIO transports - `.env` file support for environment definition -- App definition with a custom JSON file to define which tools and params are exposed to LLMs +- App definition overriding with a custom JSON file to refine descriptions and filter tools to be exposed to LLMs - Support for delegatee and delegators in HTTP transport - Delegators MUST authenticate with SIWE OR their Vincent JWT - Delegatees MUST identify with SIWE @@ -162,7 +162,7 @@ Before deploying, you'll need to create the following two files in the root of y CMD ["npx", "@lit-protocol/vincent-mcp-server", "http"] ``` -2. Create the Vincent App JSON definition file. Fill it with the data of your Vincent: ID, version, name, description and tools data. Check the [Uniswap Swap example app json](https://github.com/LIT-Protocol/Vincent/blob/main/packages/apps/mcp/vincent-app.example.json) for a complete Vincent App definition. +2. Create the Vincent App JSON definition override file. Fill it with the data of your Vincent App you want to override: ID, version, name, description and tools data. Check the [Uniswap Swap example app json](https://github.com/LIT-Protocol/Vincent/blob/main/packages/apps/mcp/vincent-app.example.json) for a complete Vincent App override file. 3. Add both files to git. Commit and push them to your repository to use as source for Heroku or Render. diff --git a/packages/apps/mcp/.env.example b/packages/apps/mcp/.env.example index 25e5883e6..36dc92ce4 100644 --- a/packages/apps/mcp/.env.example +++ b/packages/apps/mcp/.env.example @@ -10,4 +10,6 @@ VINCENT_MCP_BASE_URL=https://example.com # Vincent App config VINCENT_DELEGATEE_PRIVATE_KEY= -VINCENT_APP_JSON_DEFINITION=/your/vincent/app/definitionfile/vincent/packages/apps/mcp/vincent-app.example.json +VINCENT_APP_ID= # Optional if the json file at VINCENT_APP_JSON_DEFINITION includes an "id" +VINCENT_APP_VERSION= # Optional, will default to latest version in Vincent Registry +VINCENT_APP_JSON_DEFINITION=/your/vincent/app/definitionfile/vincent/packages/apps/mcp/vincent-app.example.json # Optional diff --git a/packages/apps/mcp/README.md b/packages/apps/mcp/README.md index 2fcbf500b..0c1685987 100644 --- a/packages/apps/mcp/README.md +++ b/packages/apps/mcp/README.md @@ -6,12 +6,14 @@ It leverages the `@lit-protocol/vincent-mcp-sdk` to build a server from a Vincen ## Setup -- Copy `vincent-app.example.json` to `vincent-app.json` or any other name you want and configure your Vincent App definition in it. -- Copy `.env.example` to `.env` and fill in the values. Use absolute paths for the `VINCENT_APP_JSON_DEFINITION` value. +- Optional: Copy `vincent-app.example.json` to `vincent-app.json` or any other name you want and configure your Vincent App definition overrides in it. If no overrides are needed, then this file can be omitted. +- Optional: Copy `.env.example` to `.env` and fill in the values. Use absolute paths for the `VINCENT_APP_JSON_DEFINITION` value. You can also pass them via CLI arguments when calling the script. -# Writing App definition JSON file +# Writing App definition overrides in a JSON file -To define the Vincent App that will be transformed into an MCP Server, a JSON definition of it must be provided. +Name and descriptions provided by developers in the registry might not be very descriptive to LLMs or you may want to modify them. + +In order to override any of those values, create a `.json` file with the following structure: ```json { @@ -19,36 +21,30 @@ To define the Vincent App that will be transformed into an MCP Server, a JSON de "version": "1", // The version of the Vincent App "name": "My Vincent App", // Name of the Vincent App. Can be overriden, doesn't have to be the same as in the registry. "description": "A Vincent application that executes tools for its delegators", // Description of the Vincent App. Can be overriden, doesn't have to be the same as in the registry. + // Adding a tools object will override what is already present in the registry. Without this field, all tools from the registry will be exposed. "tools": { - // Any tool that you want to expose to the LLM has to be included using its IPFS CID as key in this object. If a tool is not included, it is not exposed as an MCP Server tool. - "QmIpfsCid1": { - "name": "myTool", // Name of the tool. Can be overriden, doesn't have to be the same as in the registry. - "description": "A tool that does something", // Description of the tool. Can be overriden, doesn't have to be the same as in the registry. - // All parameters of the tool have to be added under this array or the LLM won't be able to see them or provide values for it - "parameters": [ - { - "name": "param1", // Name of the param. Cannot be overriden. - "type": "string", // Type of the param. Must be the type the tool expects. - "description": "A parameter that is used in the tool to do something" // Description of the param. Can be overriden. + // Any tool that you want to expose to the LLM has to be included using its NPM package name as key in this object. If a tool is not included, it is not exposed as an MCP Server tool. + "vincent-tool-npm-pkg-name": { + "name": "myTool", // Name of the tool. Defaults to npm pkg name. + "description": "A tool that does something", // Description of the tool. + "parameters": { + // Keys are the names of each param. Used to identify and apply the rest of properties. + "param1": { + "description": "A parameter that is used in the tool to do something" // Description of the param. } - // ...rest of params you want to expose. - // Any optional param that is not included here will be exposed by the tool. - ] - } + // ...rest of params you want to override. + } + }, + "vincent-tool-without-overrides": {} // Empty objects mean that the tool is exposed but with default values. } } ``` -For any value that can be overriden, consider that those are the hints the LLM uses to know how to use the tool. Therefore, those are good places to provide any information you want the LLM to know about the tool such as units, formats, examples or pre-conditions to check. - -If you are the owner of the app, most of the data can be obtained from the Vincent App page in the Vincent dashboard. +When the `tools` property is omitted, all tools from the registry will be exposed. When overriding at least one tool, you need to specify all others that you want to still expose as MCP tools, even with empty values inside. -If you are not the owner of the app, the tool fields and its included tools IPFS CIDs are shown in the consent screen. - -The IPFS CID can also be obtained from the bundled tool code published in npm. For example [vincent-tool-metadata.json](../tool-erc20-approval/src/generated/vincent-tool-metadata.json) for our ERC20 approval tool. -To get the tool params from source code, you can check the tool schemas such as [schemas.ts](../tool-erc20-approval/src/lib/schemas.ts) for our ERC20 approval tool. +For any value that can be overriden, consider that those are the hints the LLM uses to know how to use the tool. Therefore, those are good places to provide any information you want the LLM to know about the tool such as units, formats, examples or pre-conditions to check. -Any tool created using our [Tools and Policies SDK](https://www.npmjs.com/package/@lit-protocol/vincent-tool-sdk) will provide those files. +Tools included in definition MUST be published in NPM and imported using their package names. Also, they must be part of the Vincent App and recorded in the Vincent Registry. Any tool that is not part of that specific app will fail its invocation. # Running @@ -66,10 +62,14 @@ You can run the Vincent MCP server directly using npx without downloading the re npx @lit-protocol/vincent-mcp-server stdio ``` -When setting this in the LLM client, pass it the necessary environment variables from your client. These env variables include: +When setting this in the LLM client, pass it the necessary environment variables from your LLM client. These env variables include: -- `VINCENT_APP_JSON_DEFINITION`: Path to your Vincent App definition JSON file -- `VINCENT_DELEGATEE_PRIVATE_KEY`: The private key of the delegatee. This is the one you added in the Vincent App Dashboard as [an authorized signer for your app](https://docs.heyvincent.ai/documents/Quick_Start.html#:~:text=New%20App%22%20button.-,Delegatees,-%3A%20Delegatees%20are). This private key MUST be an allowed delegatee of the Vincent App defined in the JSON. +- `VINCENT_DELEGATEE_PRIVATE_KEY`: The private key of the delegatee. This is the one you added in the Vincent App Dashboard as [an authorized signer for your app](https://docs.heyvincent.ai/documents/Quick_Start.html#:~:text=New%20App%22%20button.-,Delegatees,-%3A%20Delegatees%20are). This private key MUST be an allowed delegatee of the Vincent App. +- (Optional) `VINCENT_APP_ID`: The Vincent App Id you want to run as an MCP Server +- (Optional) `VINCENT_APP_VERSION`: The Vincent App Version you want to run as an MCP Server +- (Optional) `VINCENT_APP_JSON_DEFINITION`: Path to your Vincent App overrides JSON file + +Note: The environment MUST include the Vincent App Id, either via the `VINCENT_APP_ID` env variable or in the App definition JSON file. The version is completely optional as it will default to the latest version specified in the registry. ### HTTP mode @@ -79,28 +79,30 @@ npx @lit-protocol/vincent-mcp-server http In HTTP mode, the environment variables are configured on the server itself, not the client running it. -These commands require the following environment variables to be set: +To configure runtime environment in this mode, set the following environment variables: +- `VINCENT_DELEGATEE_PRIVATE_KEY`: The private key of the delegatee. This is the one you added in the Vincent App Dashboard as [an authorized signer for your app](https://docs.heyvincent.ai/documents/Quick_Start.html#:~:text=New%20App%22%20button.-,Delegatees,-%3A%20Delegatees%20are). This private key MUST be an allowed delegatee of the Vincent App. +- (Optional) `VINCENT_APP_ID`: The Vincent App Id you want to run as an MCP Server +- (Optional) `VINCENT_APP_VERSION`: The Vincent App Version you want to run as an MCP Server +- (Optional) `VINCENT_APP_JSON_DEFINITION`: Path to your Vincent App overrides JSON file - `EXPECTED_AUDIENCE`: The audience that you expect JWTs to have. Vincent populates this with the redirect URLs. Likely you want this server to be one of those URLs. -- `VINCENT_APP_JSON_DEFINITION`: Path to your Vincent App definition JSON file -- `VINCENT_DELEGATEE_PRIVATE_KEY`: The private key of the delegatee. This is the one you added in the Vincent App Dashboard as [an authorized signer for your app](https://docs.heyvincent.ai/documents/Quick_Start.html#:~:text=New%20App%22%20button.-,Delegatees,-%3A%20Delegatees%20are). -- `VINCENT_MCP_BASE_URL`: This MCP server URL -- `PORT` (for HTTP mode only): The port to run the HTTP server on (defaults to 3000) - -Other optional environment variables include: +- `VINCENT_MCP_BASE_URL`: This MCP server URL. Used to generate SIWE messages and verify signatures +- `VINCENT_REGISTRY_URL`: This Vincent Registry server URL. Will be queried to get the Vincent App and its tools info +- (Optional) `PORT`: The port to run the HTTP server on (defaults to 3000) +- (Optional) `HTTP_TRANSPORT_CLEAN_INTERVAL`: Defines the interval (milliseconds) that the server will use to clean unused transports. Defaults to 1 hour +- (Optional) `HTTP_TRANSPORT_TTL`: Defines the time (milliseconds) that a transport will still be considered in use after the last time it was actually used. Defaults to 1 hour +- (Optional) `SIWE_EXPIRATION_TIME`: Duration of the generated SIWE message to sign. Defaults to 1 hour +- (Optional) `SIWE_NONCE_CLEAN_INTERVAL`: Defines the interval (milliseconds) that the server will use to clean unused transports. Defaults to 1 hour +- (Optional) `SIWE_NONCE_TTL`: Defines the time (milliseconds) that a SIWE nonce will still be considered valid after it was created. Defaults to 5 minutes -- `HTTP_TRANSPORT_CLEAN_INTERVAL`: Defines the interval (milliseconds) that the server will use to clean unused transports. Defaults to 1 hour -- `HTTP_TRANSPORT_TTL`: Defines the time (milliseconds) that a transport will still be considered in use after the last time it was actually used. Defaults to 1 hour -- `SIWE_EXPIRATION_TIME`: Duration of the generated SIWE message to sign. Defaults to 1 hour -- `SIWE_NONCE_CLEAN_INTERVAL`: Defines the interval (milliseconds) that the server will use to clean unused transports. Defaults to 1 hour -- `SIWE_NONCE_TTL`: Defines the time (milliseconds) that a SIWE nonce will still be considered valid after it was created. Defaults to 5 minutes +Note: The environment MUST include the Vincent App Id, either via the `VINCENT_APP_ID` env variable or in the App definition JSON file. The version is completely optional as it will default to the latest version specified in the registry. Consider that a SIWE message must have a valid nonce, so it will become invalid after reaching the expiration time or the nonce has been discarded. You can set these environment variables in your shell before running the commands, or use a tool like `dotenvx`: ```bash -dotenvx run -f /path/to/.env -- npx @lit-protocol/vincent-mcp-server http +dotenvx run -f /path/to/.env -- npx -y @lit-protocol/vincent-mcp-server http ``` For an .env file example check [./.env.example](./.env.example) diff --git a/packages/apps/mcp/package.json b/packages/apps/mcp/package.json index 3a0d960cf..2f5c4f278 100644 --- a/packages/apps/mcp/package.json +++ b/packages/apps/mcp/package.json @@ -29,17 +29,22 @@ "main": "./dist/src/bin/cli.js", "scripts": { "dev:http": "tsx watch --tsconfig ./tsconfig.app.json --env-file=.env src/bin/http.ts", + "dev:stdio": "tsx watch --tsconfig ./tsconfig.app.json --env-file=.env src/bin/stdio.ts", "inspector": "npx @modelcontextprotocol/inspector" }, "dependencies": { "@lit-protocol/constants": "^7.1.1", "@lit-protocol/vincent-app-sdk": "workspace:*", "@lit-protocol/vincent-mcp-sdk": "workspace:*", + "@lit-protocol/vincent-registry-sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.12.1", + "@reduxjs/toolkit": "^2.8.2", "@t3-oss/env-core": "^0.13.4", "cors": "^2.8.5", "ethers": "^5.8.0", "express": "^5.1.0", + "node-cache": "^5.1.2", + "npx-import": "^1.1.4", "siwe": "^3.0.0", "tslib": "^2.8.1", "zod": "^3.25.64" diff --git a/packages/apps/mcp/src/appDefBuilder.ts b/packages/apps/mcp/src/appDefBuilder.ts new file mode 100644 index 000000000..571199a5a --- /dev/null +++ b/packages/apps/mcp/src/appDefBuilder.ts @@ -0,0 +1,268 @@ +import fs from 'node:fs'; + +import { VincentAppDefSchema, VincentToolDefSchema } from '@lit-protocol/vincent-mcp-sdk'; +import type { + BundledVincentTool, + VincentAppDef, + VincentAppTools, + VincentParameter, +} from '@lit-protocol/vincent-mcp-sdk'; +import { nodeClient } from '@lit-protocol/vincent-registry-sdk'; +import { npxImport } from 'npx-import'; +import { z, ZodObject } from 'zod'; + +import { env } from './env/base'; +import { store as registryStore } from './registry'; + +const { VINCENT_APP_ID, VINCENT_APP_JSON_DEFINITION, VINCENT_APP_VERSION } = env; +const { vincentApiClientNode } = nodeClient; + +/** + * Zod schema for Vincent application tools defined in a JSON file. + * This schema omits the `version` and bundled action fields, as it's expected to come from the registry or loaded at runtime. + * @hidden + */ +const JsonVincentAppToolsSchema = z.record(VincentToolDefSchema); + +/** + * Zod schema for a Vincent application definition provided in a JSON file. + * This schema allows for partial definitions, which will be merged with data from the registry. + * @hidden + */ +const JsonVincentAppSchema = VincentAppDefSchema.extend({ + tools: JsonVincentAppToolsSchema, +}).partial(); + +/** + * Type representing a collection of Vincent application tools defined in a JSON file. + * @hidden + */ +type JsonVincentAppTools = z.infer; + +/** + * Type representing a Vincent Tool NPM package. + * @hidden + */ +type ToolPackage = { + bundledVincentTool: BundledVincentTool; +}; + +/** + * Type representing a collection of Vincent application tools defined in the registry. + * @hidden + */ +type VersionedVincentAppTools = Record; + +/** + * Builds Tools definitions based on registry data making registry data self-sufficient for app definition + * + * This function dynamically imports each tool package to access its bundled information, + * such as parameter schemas, and updates the tool definitions accordingly. + * + * @param {VincentAppTools} tools - The initial tool definitions, typically from the registry. + * @returns {Promise} A promise that resolves with the enriched tool definitions. + * @hidden + */ +async function buildRegistryVincentTools( + tools: VersionedVincentAppTools, +): Promise { + const packagesToInstall = Object.entries(tools).map(([toolNpmName, pkgInfo]) => { + return `${toolNpmName}@${pkgInfo.version}`; + }); + const toolsPkgs = await npxImport(packagesToInstall); + + const toolsObject: VincentAppTools = {}; + for (const [toolPackage, toolData] of Object.entries(tools)) { + const tool = toolsPkgs.find( + (tool) => toolPackage === tool.bundledVincentTool.vincentTool.packageName, + ); + if (!tool) { + throw new Error(`Tried to import tool ${toolPackage} but could not find it`); + } + + const { bundledVincentTool } = tool; + const { vincentTool } = bundledVincentTool; + const { toolDescription, toolParamsSchema } = vincentTool; + const paramsSchema = toolParamsSchema.shape as ZodObject; + + const parameters = {} as Record; + Object.entries(paramsSchema).forEach(([key, value]) => { + parameters[key] = { + ...(value.description ? { description: value.description } : {}), + }; + }); + + // Add all tool fields to the object + toolsObject[toolPackage] = { + bundledVincentTool, + description: toolDescription, + name: toolPackage, + parameters, + version: toolData.version, + }; + } + + return toolsObject; +} + +/** + * Fetches the application definition from the Vincent Registry. + * + * This includes the app's metadata and the list of associated tools for the active version. + * + * @param {string} appId - The ID of the Vincent application. + * @param {string} appVersion - The version of the Vincent application. + * @returns {Promise} A promise that resolves with the application definition from the registry. + * @hidden + */ +async function getAppDataFromRegistry( + appId: string, + appVersion: string | undefined, +): Promise { + const registryAppQuery = await registryStore.dispatch( + vincentApiClientNode.endpoints.getApp.initiate({ appId: Number(appId) }), + ); + const registryApp = registryAppQuery.data; + if (!registryApp) { + throw new Error(`Failed to retrieve registry app data for Vincent App ${appId}.`); + } + if (registryApp.isDeleted) { + throw new Error(`Vincent App ${appId} has been deleted from the registry`); + } + if (registryApp.deploymentStatus !== 'prod') { + console.warn( + `Warning: Vincent App ${appId} is deployed as ${registryApp.deploymentStatus}. Consider migrating to a production deployment.`, + ); + } + + const vincentAppVersion = Number(appVersion) || registryApp.activeVersion; + if (!vincentAppVersion) { + throw new Error( + `Failed to define Vincent App version for ${appId}. Either specify a version in the app definition file, set the VINCENT_APP_VERSION environment variable or ensure the registry has an active version.`, + ); + } + + const vincentAppVersionQuery = await registryStore.dispatch( + vincentApiClientNode.endpoints.getAppVersion.initiate({ + appId: Number(appId), + version: vincentAppVersion, + }), + ); + const vincentAppVersionData = vincentAppVersionQuery.data; + if (!vincentAppVersionData) { + throw new Error(`Failed to retrieve Vincent App version data for Vincent App ${appId}.`); + } + if (!vincentAppVersionData.enabled) { + throw new Error(`Vincent App Version ${vincentAppVersion} is not enabled in the registry.`); + } + + const registryToolsQuery = await registryStore.dispatch( + vincentApiClientNode.endpoints.listAppVersionTools.initiate({ + appId: Number(appId), + version: vincentAppVersion, + }), + ); + const registryToolsData = registryToolsQuery.data; + if (!registryToolsData) { + throw new Error(`Failed to retrieve tools for Vincent App ${appId}.`); + } + + const toolVersionsObject: VersionedVincentAppTools = {}; + registryToolsData.forEach((rt) => { + if (rt.isDeleted) { + console.warn( + `Vincent App Version Tool ${rt.toolPackageName}@${rt.toolVersion} has been deleted from the registry. Will not be included in the app definition.`, + ); + } else { + toolVersionsObject[rt.toolPackageName] = { + version: rt.toolVersion, + }; + } + }); + + const fullToolsObject = await buildRegistryVincentTools(toolVersionsObject); + + return { + id: appId, + version: vincentAppVersion.toString(), + name: registryApp.name, + description: registryApp?.description, + tools: fullToolsObject, + }; +} + +/** + * Merges tool definitions from a JSON file with those from the registry. + * + * Properties in the JSON file will override the corresponding properties from the registry. + * + * @param {JsonVincentAppTools | undefined} jsonTools - The tool definitions from the JSON file. + * @param {VincentAppTools} registryTools - The tool definitions from the registry. + * @returns {VincentAppTools} The merged tool definitions. + * @hidden + */ +function mergeToolData( + jsonTools: JsonVincentAppTools | undefined, + registryTools: VincentAppTools, +): VincentAppTools { + if (!jsonTools) return registryTools; + + const mergedTools: VincentAppTools = {}; + Object.entries(jsonTools).forEach(([toolKey, toolValue]) => { + if (!registryTools[toolKey]) { + throw new Error(`Tool ${toolKey} from app def file not found in registry`); + } + + mergedTools[toolKey] = Object.assign({}, registryTools[toolKey], toolValue); + }); + + return mergedTools; +} + +/** + * Constructs the complete Vincent application definition. + * + * This function orchestrates the process of fetching the base application definition from the + * Vincent Registry and merging it with any local overrides provided in a JSON file + * (specified by `VINCENT_APP_JSON_DEFINITION`). It also handles the installation of + * required tool packages. The final app definition is validated against the schema. + * + * @returns {Promise} A promise that resolves with the final, validated Vincent application definition. + */ +export async function getVincentAppDef(): Promise { + // Load data from the App definition JSON + const appJson = VINCENT_APP_JSON_DEFINITION + ? fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }) + : '{}'; + const jsonData = JsonVincentAppSchema.parse(JSON.parse(appJson)) as Partial; + + if (!VINCENT_APP_ID && !jsonData.id) { + throw new Error( + 'VINCENT_APP_ID is not set and no app.json file was provided. Need Vincent App Id in one of those sources', + ); + } + if (jsonData.id && VINCENT_APP_ID && jsonData.id !== VINCENT_APP_ID) { + console.warn( + `The Vincent App Id specified in the environment variable VINCENT_APP_ID (${VINCENT_APP_ID}) does not match the Id in ${VINCENT_APP_JSON_DEFINITION} (${jsonData.id}). Using the Id from the file...`, + ); + } + if (jsonData.id && VINCENT_APP_VERSION && jsonData.version !== VINCENT_APP_VERSION) { + console.warn( + `The Vincent App version specified in the environment variable VINCENT_APP_VERSION (${VINCENT_APP_VERSION}) does not match the version in ${VINCENT_APP_JSON_DEFINITION} (${jsonData.version}). Using the version from the file...`, + ); + } + + const vincentAppId = jsonData.id ?? (VINCENT_APP_ID as string); + const vincentAppVersion = jsonData.version ?? VINCENT_APP_VERSION; + const registryData = await getAppDataFromRegistry(vincentAppId, vincentAppVersion); + + const vincentAppDef = VincentAppDefSchema.parse({ + id: vincentAppId, + name: jsonData.name || registryData.name, + version: jsonData.version || registryData.version, + description: jsonData.description || registryData.description, + tools: mergeToolData(jsonData.tools, registryData.tools), + }); + + return vincentAppDef; +} diff --git a/packages/apps/mcp/src/authentication.ts b/packages/apps/mcp/src/authentication.ts index c6af74b52..00ddfd159 100644 --- a/packages/apps/mcp/src/authentication.ts +++ b/packages/apps/mcp/src/authentication.ts @@ -4,19 +4,13 @@ import { SiweMessage } from 'siwe'; import { nonceManager } from './nonceManager'; -import { env } from './env'; +import { env } from './env/http'; const { EXPECTED_AUDIENCE, SIWE_EXPIRATION_TIME, VINCENT_MCP_BASE_URL } = env; const { verify } = jwt; const YELLOWSTONE = LIT_EVM_CHAINS.yellowstone; -if (!EXPECTED_AUDIENCE || !VINCENT_MCP_BASE_URL) { - throw new Error( - '"EXPECTED_AUDIENCE" or "VINCENT_MCP_BASE_URL" environment variable missing. They are required for proper authentication', - ); -} - /** * Generates a SIWE (Sign-In with Ethereum) message for authentication. * This message needs to be signed by the user's wallet to prove ownership of the address. @@ -56,15 +50,16 @@ export async function authenticateWithSiwe( signature: string, ): Promise { const siweMsg = new SiweMessage(messageToSign); - const verification = await siweMsg.verify({ signature }); + const verification = await siweMsg.verify({ domain: VINCENT_MCP_BASE_URL, signature }); - const { address, domain, nonce, uri } = verification.data; + const { address, expirationTime, issuedAt, nonce, uri } = verification.data; if ( !verification.success || + !issuedAt || + !expirationTime || !nonceManager.consumeNonce(address, nonce) || - // @ts-expect-error Env var is defined or this module would have thrown - domain !== new URL(VINCENT_MCP_BASE_URL).host || // Env var is defined or this module would have thrown + new Date(issuedAt).getTime() + SIWE_EXPIRATION_TIME >= new Date(expirationTime).getTime() || uri !== EXPECTED_AUDIENCE ) { throw new Error('SIWE message verification failed'); @@ -84,7 +79,6 @@ export async function authenticateWithSiwe( * @throws {Error} If the JWT is invalid or doesn't match the expected app ID/version */ export function authenticateWithJwt(jwt: string, appId: string, appVersion: string): string { - // @ts-expect-error Env var is defined or this module would have thrown const decodedJwt = verify(jwt, EXPECTED_AUDIENCE); const { id, version } = decodedJwt.payload.app; if (id !== appId || version !== parseInt(appVersion)) { diff --git a/packages/apps/mcp/src/bin/http.ts b/packages/apps/mcp/src/bin/http.ts index 86f32a304..e549e2fad 100644 --- a/packages/apps/mcp/src/bin/http.ts +++ b/packages/apps/mcp/src/bin/http.ts @@ -14,33 +14,33 @@ * @category Vincent MCP */ -import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; -import { VincentAppDefSchema } from '@lit-protocol/vincent-mcp-sdk'; +import { disconnectVincentToolClients } from '@lit-protocol/vincent-app-sdk'; +import { VincentAppDef } from '@lit-protocol/vincent-mcp-sdk'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import cors from 'cors'; import { ethers } from 'ethers'; import express, { Request, Response } from 'express'; +import { getVincentAppDef } from '../appDefBuilder'; import { + authenticateWithJwt, authenticateWithSiwe, getSiweMessageToAuthenticate, - authenticateWithJwt, } from '../authentication'; -import { env } from '../env'; +import { env } from '../env/http'; +import { nonceManager } from '../nonceManager'; import { getServer } from '../server'; import { transportManager } from '../transportManager'; -const { PORT, VINCENT_APP_JSON_DEFINITION, VINCENT_DELEGATEE_PRIVATE_KEY } = env; +const { PORT, VINCENT_DELEGATEE_PRIVATE_KEY } = env; const YELLOWSTONE = LIT_EVM_CHAINS.yellowstone; - -const vincentAppJson = fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }); -const vincentAppDef = VincentAppDefSchema.parse(JSON.parse(vincentAppJson)); +let appDef: VincentAppDef | undefined; const delegateeSigner = new ethers.Wallet( VINCENT_DELEGATEE_PRIVATE_KEY, @@ -117,8 +117,8 @@ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../public', 'index.html')); }); -app.get('/appDef', (req, res) => { - res.sendFile(VINCENT_APP_JSON_DEFINITION); +app.get('/appDef', async (req, res) => { + res.status(200).json(appDef); }); app.get('/siwe', async (req: Request, res: Response) => { @@ -138,6 +138,14 @@ app.get('/siwe', async (req: Request, res: Response) => { app.post('/mcp', async (req: Request, res: Response) => { try { + if (!appDef) { + return sendJsonRPCErrorResponse( + res, + 500, + 'Vincent App Definition has not been loaded. Restart server', + ); + } + const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; @@ -211,7 +219,7 @@ app.post('/mcp', async (req: Request, res: Response) => { ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await authenticateWithSiwe(message!, signature!) : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - authenticateWithJwt(jwt!, vincentAppDef.id, vincentAppDef.version); + authenticateWithJwt(jwt!, appDef.id, appDef.version); } catch (e) { console.error(`Client authentication failed: ${(e as Error).message}`); return sendJsonRPCErrorResponse( @@ -226,7 +234,7 @@ app.post('/mcp', async (req: Request, res: Response) => { const delegatorPkpEthAddress = authenticatedAddress !== delegateeSigner.address ? authenticatedAddress : undefined; - const server = await getServer(vincentAppDef, { + const server = await getServer(appDef, { delegateeSigner, delegatorPkpEthAddress, }); @@ -257,7 +265,7 @@ app.post('/mcp', async (req: Request, res: Response) => { * * @param req - The Express request object * @param res - The Express response object - * @internal + * @hidden */ const handleSessionRequest = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -284,6 +292,29 @@ app.delete('/mcp', async (req: Request, res: Response) => { return sendJsonRPCErrorResponse(res, 405, 'Method not allowed'); }); -app.listen(PORT, () => { - console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); +async function startServer() { + appDef = await getVincentAppDef(); + + const server = app.listen(PORT, () => { + console.log(`Vincent MCP Server listening on port ${PORT}`); + }); + + function gracefulShutdown() { + console.log('🔌 Disconnecting from Lit Network...'); + + disconnectVincentToolClients(); + nonceManager.closeNonceManager(); + transportManager.closeTransportManager(); + + server.close(() => { + console.log('🛑 Vincent MCP Server has been closed.'); + process.exitCode = 0; + }); + } + process.once('SIGINT', gracefulShutdown); + process.once('SIGTERM', gracefulShutdown); +} +startServer().catch((error) => { + console.error('Fatal error starting Vincent MCP server in HTTP mode:', error); + process.exit(1); }); diff --git a/packages/apps/mcp/src/bin/stdio.ts b/packages/apps/mcp/src/bin/stdio.ts index 305e40f4d..f8e7f6c49 100644 --- a/packages/apps/mcp/src/bin/stdio.ts +++ b/packages/apps/mcp/src/bin/stdio.ts @@ -18,16 +18,15 @@ import '../bootstrap'; // Bootstrap console.log to a log file import { ethers } from 'ethers'; -import fs from 'node:fs'; - import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; -import { VincentAppDefSchema } from '@lit-protocol/vincent-mcp-sdk'; +import { disconnectVincentToolClients } from '@lit-protocol/vincent-app-sdk'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { env } from '../env'; +import { getVincentAppDef } from '../appDefBuilder'; +import { env } from '../env/base'; import { getServer } from '../server'; -const { VINCENT_APP_JSON_DEFINITION, VINCENT_DELEGATEE_PRIVATE_KEY } = env; +const { VINCENT_DELEGATEE_PRIVATE_KEY } = env; const delegateeSigner = new ethers.Wallet( VINCENT_DELEGATEE_PRIVATE_KEY, @@ -44,22 +43,31 @@ const delegateeSigner = new ethers.Wallet( * 4. Connects the server to the stdio transport * 5. Logs a message to stderr indicating the server is running * - * @internal + * @hidden */ async function main() { - const stdioTransport = new StdioServerTransport(); - const vincentAppJson = fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }); - const vincentAppDef = VincentAppDefSchema.parse(JSON.parse(vincentAppJson)); + const vincentAppDef = await getVincentAppDef(); const server = await getServer(vincentAppDef, { delegateeSigner, delegatorPkpEthAddress: undefined, // STDIO is ALWAYS running in a local environment }); - await server.connect(stdioTransport); - console.error('Vincent MCP Server running in stdio mode'); // console.log is used for messaging the parent process + await server.connect(new StdioServerTransport()); + console.error('Vincent MCP Server running in STDIO mode'); // console.log is used for messaging the parent process + + function gracefulShutdown() { + console.error('🔌 Disconnecting from Lit Network...'); + + disconnectVincentToolClients(); + + console.error('🛑 Vincent MCP Server has been closed.'); + process.exitCode = 0; + } + process.once('SIGINT', gracefulShutdown); + process.once('SIGTERM', gracefulShutdown); } main().catch((error) => { - console.error('Fatal error in main():', error); + console.error('Fatal error starting MCP server in STDIO mode:', error); process.exit(1); }); diff --git a/packages/apps/mcp/src/env.ts b/packages/apps/mcp/src/env/base.ts similarity index 63% rename from packages/apps/mcp/src/env.ts rename to packages/apps/mcp/src/env/base.ts index 7576b0396..0e018a76b 100644 --- a/packages/apps/mcp/src/env.ts +++ b/packages/apps/mcp/src/env/base.ts @@ -20,23 +20,14 @@ const BooleanOrBooleanStringSchema = z throw new Error(`Expected boolean or boolean string, got: ${typeof val}`); }); -const FIVE_MIN = 5 * 60 * 1000; -const ONE_HOUR = 60 * 60 * 1000; - -// TODO make http server required params truly required and hide http ones at stdio export const env = createEnv({ emptyStringAsUndefined: true, runtimeEnv: process.env, server: { - EXPECTED_AUDIENCE: z.string().optional(), - PORT: z.coerce.number().default(3000), - HTTP_TRANSPORT_CLEAN_INTERVAL: z.coerce.number().default(ONE_HOUR), - HTTP_TRANSPORT_TTL: z.coerce.number().default(ONE_HOUR), - SIWE_EXPIRATION_TIME: z.coerce.number().default(ONE_HOUR), - SIWE_NONCE_CLEAN_INTERVAL: z.coerce.number().default(ONE_HOUR), - SIWE_NONCE_TTL: z.coerce.number().default(FIVE_MIN), - VINCENT_APP_JSON_DEFINITION: z.string(), + VINCENT_APP_ID: z.string().optional(), + VINCENT_APP_VERSION: z.string().optional(), + VINCENT_APP_JSON_DEFINITION: z.string().optional(), VINCENT_DELEGATEE_PRIVATE_KEY: z.string(), - VINCENT_MCP_BASE_URL: z.string().optional(), + VINCENT_REGISTRY_URL: z.string().default('https://registry.heyvincent.ai'), }, }); diff --git a/packages/apps/mcp/src/env/http.ts b/packages/apps/mcp/src/env/http.ts new file mode 100644 index 000000000..628253a0e --- /dev/null +++ b/packages/apps/mcp/src/env/http.ts @@ -0,0 +1,27 @@ +import { createEnv } from '@t3-oss/env-core'; +import { z } from 'zod'; + +import { env as baseEnv } from './base'; + +const FIVE_MIN = 5 * 60 * 1000; +const ONE_HOUR = 60 * 60 * 1000; + +const httpEnv = createEnv({ + emptyStringAsUndefined: true, + runtimeEnv: process.env, + server: { + EXPECTED_AUDIENCE: z.string(), + PORT: z.coerce.number().default(3000), + HTTP_TRANSPORT_CLEAN_INTERVAL: z.coerce.number().default(ONE_HOUR), + HTTP_TRANSPORT_TTL: z.coerce.number().default(ONE_HOUR), + SIWE_EXPIRATION_TIME: z.coerce.number().default(ONE_HOUR), + SIWE_NONCE_CLEAN_INTERVAL: z.coerce.number().default(ONE_HOUR), + SIWE_NONCE_TTL: z.coerce.number().default(FIVE_MIN), + VINCENT_MCP_BASE_URL: z.string(), + }, +}); + +export const env = Object.freeze({ + ...baseEnv, + ...httpEnv, +}); diff --git a/packages/apps/mcp/src/extensions.ts b/packages/apps/mcp/src/extensions.ts index 7fd7736f8..2e30dd4cb 100644 --- a/packages/apps/mcp/src/extensions.ts +++ b/packages/apps/mcp/src/extensions.ts @@ -40,7 +40,7 @@ const ERC20_ABI = [ * @param chainId - The chain ID as a string * @returns The chain configuration data * @throws Error if Lit does not support the chain - * @internal + * @hidden */ function getLitSupportedChainData(chainId: number) { const litSupportedChain = Object.values(LIT_EVM_CHAINS).find( @@ -108,18 +108,23 @@ export function extendVincentServer( delegateeSigner: ethers.Signer, ) { // Add more resources, tools, and prompts as needed - server.tool( + server.registerTool( buildMcpToolName(vincentAppDefinition, 'native-balance'), - 'Resource to get the native balance for a given PKP ETH address on a given chain.', { - chainId: z.coerce - .number() - .describe('The chain ID to execute the query the balance on. For example: 8453 for Base.'), - pkpEthAddress: z - .string() - .describe( - 'The PKP address to query the balance for. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.', - ), + description: + 'Resource to get the native balance for a given PKP ETH address on a given chain.', + inputSchema: { + chainId: z.coerce + .number() + .describe( + 'The chain ID to execute the query the balance on. For example: 8453 for Base.', + ), + pkpEthAddress: z + .string() + .describe( + 'The PKP address to query the balance for. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.', + ), + }, }, async ({ chainId, pkpEthAddress }) => { const chain = getLitSupportedChainData(chainId); @@ -137,23 +142,28 @@ export function extendVincentServer( }, ); - server.tool( + server.registerTool( buildMcpToolName(vincentAppDefinition, 'erc20-balance'), - 'Resource to get the ERC20 balance for a given PKP ETH address on a given chain.', { - chainId: z.coerce - .number() - .describe('The chain ID to execute the query the balance on. For example: 8453 for Base.'), - pkpEthAddress: z - .string() - .describe( - "The delegator's PKP address. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.", - ), - tokenAddress: z - .string() - .describe( - 'The ERC20 token address to query the balance for. For example 0x4200000000000000000000000000000000000006 for WETH on Base.', - ), + description: + 'Resource to get the ERC20 balance for a given PKP ETH address on a given chain.', + inputSchema: { + chainId: z.coerce + .number() + .describe( + 'The chain ID to execute the query the balance on. For example: 8453 for Base.', + ), + pkpEthAddress: z + .string() + .describe( + "The delegator's PKP address. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.", + ), + tokenAddress: z + .string() + .describe( + 'The ERC20 token address to query the balance for. For example 0x4200000000000000000000000000000000000006 for WETH on Base.', + ), + }, }, async ({ chainId, pkpEthAddress, tokenAddress }) => { const chain = getLitSupportedChainData(chainId); @@ -175,30 +185,33 @@ export function extendVincentServer( }, ); - server.tool( + server.registerTool( buildMcpToolName(vincentAppDefinition, 'erc20-allowance'), - 'Resource to get the ERC20 allowance for a given PKP ETH address on a given chain.', { - chainId: z.coerce - .number() - .describe( - 'The chain ID to execute the query the allowance on. For example: 8453 for Base.', - ), - pkpEthAddress: z - .string() - .describe( - "The delegator's PKP address. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.", - ), - tokenAddress: z - .string() - .describe( - 'The ERC20 token address to query the allowance for. For example 0x4200000000000000000000000000000000000006 for WETH on Base.', - ), - spenderAddress: z - .string() - .describe( - 'The spender address to query the allowance for. For example 0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed for Uniswap V3 Router on Base.', - ), + description: + 'Resource to get the ERC20 allowance for a given PKP ETH address on a given chain.', + inputSchema: { + chainId: z.coerce + .number() + .describe( + 'The chain ID to execute the query the allowance on. For example: 8453 for Base.', + ), + pkpEthAddress: z + .string() + .describe( + "The delegator's PKP address. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.", + ), + tokenAddress: z + .string() + .describe( + 'The ERC20 token address to query the allowance for. For example 0x4200000000000000000000000000000000000006 for WETH on Base.', + ), + spenderAddress: z + .string() + .describe( + 'The spender address to query the allowance for. For example 0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed for Uniswap V3 Router on Base.', + ), + }, }, async ({ chainId, pkpEthAddress, spenderAddress, tokenAddress }) => { const chain = getLitSupportedChainData(chainId); diff --git a/packages/apps/mcp/src/nonceManager.ts b/packages/apps/mcp/src/nonceManager.ts index da842758d..d2ab23537 100644 --- a/packages/apps/mcp/src/nonceManager.ts +++ b/packages/apps/mcp/src/nonceManager.ts @@ -1,54 +1,55 @@ +import NodeCache from 'node-cache'; import { generateNonce } from 'siwe'; -import { env } from './env'; +import { env } from './env/http'; const { SIWE_NONCE_CLEAN_INTERVAL, SIWE_NONCE_TTL } = env; class NonceManager { - private readonly nonces: { - [address: string]: { - ttl: number; - nonce: string; - }[]; - } = {}; + private readonly nonceCache: NodeCache; constructor() { - setInterval(() => { - const now = Date.now(); - for (const [address, nonces] of Object.entries(this.nonces)) { - this.nonces[address] = nonces.filter((n) => n.ttl > now); - } - }, SIWE_NONCE_CLEAN_INTERVAL); + this.nonceCache = new NodeCache({ + checkperiod: SIWE_NONCE_CLEAN_INTERVAL / 1000, + deleteOnExpire: true, + stdTTL: SIWE_NONCE_TTL / 1000, + useClones: false, // Set useClones to false to store arrays by reference + }); + } + + closeNonceManager() { + this.nonceCache.flushAll(); + } + + private getNoncesForAddress(address: string): string[] { + return this.nonceCache.get(address) || []; + } + + private setNoncesForAddress(address: string, nonces: string[]): void { + this.nonceCache.set(address, nonces, SIWE_NONCE_TTL / 1000); } getNonce(address: string): string { const nonce = generateNonce(); + const nonces = this.getNoncesForAddress(address); - if (!this.nonces[address]) { - this.nonces[address] = []; - } - - this.nonces[address].push({ - ttl: Date.now() + SIWE_NONCE_TTL, - nonce, - }); + nonces.push(nonce); + this.setNoncesForAddress(address, nonces); return nonce; } consumeNonce(address: string, nonce: string): boolean { - const addressNonces = this.nonces[address] || []; - - const now = Date.now(); - const consumedNonce = addressNonces.find((n) => n.nonce === nonce && n.ttl > now); + const nonces = this.getNoncesForAddress(address); + const nonceIndex = nonces.indexOf(nonce); - // Consumed nonce should be removed. But some clients are doing several repeated requests with it. TTL will handle it - // if (consumedNonce) { - // // Remove consumed nonce - // this.nonces[address] = addressNonces.filter((n) => n.nonce !== nonce); - // } + if (nonceIndex !== -1) { + // Nonce found and valid (since it's in the cache, it hasn't expired) + // We don't remove it to handle repeated requests from clients + return true; + } - return !!consumedNonce; + return false; } } diff --git a/packages/apps/mcp/src/public/script.js b/packages/apps/mcp/src/public/script.js index 65c00b1cc..80b8804c1 100644 --- a/packages/apps/mcp/src/public/script.js +++ b/packages/apps/mcp/src/public/script.js @@ -361,24 +361,36 @@ function displayAppDefinition(appDef) { const toolsContainer = document.getElementById('tools-container'); toolsContainer.innerHTML = ''; - Object.values(tools).forEach((tool) => { - const toolElement = createToolElement(tool); + Object.entries(tools).forEach(([toolKey, tool]) => { + const toolElement = createToolElement(toolKey, tool); toolsContainer.appendChild(toolElement); }); } /** * Creates a DOM element for a tool + * @param {string} toolKey - The key of the tool * @param {Object} tool - The tool object * @returns {HTMLElement} The created tool element */ -function createToolElement(tool) { +function createToolElement(toolKey, tool) { const toolElement = document.createElement('div'); toolElement.className = 'tool-card'; + const toolName = tool.name || toolKey; + const toolVersionSpan = tool.version + ? `v${escapeHtml(String(tool.version))}` + : ''; + const toolDescriptionP = tool.description + ? `

${escapeHtml(tool.description)}

` + : ''; + toolElement.innerHTML = ` -

${escapeHtml(tool.name)}

-

${escapeHtml(tool.description)}

+
+

${escapeHtml(toolName)}

+ ${toolVersionSpan} +
+ ${toolDescriptionP} `; if (tool.parameters && tool.parameters.length > 0) { @@ -395,17 +407,16 @@ function createToolElement(tool) { const nameElement = document.createElement('div'); nameElement.className = 'parameter-name'; - nameElement.innerHTML = ` - ${escapeHtml(param.name)} - ${escapeHtml(param.type)} - `; + nameElement.textContent = escapeHtml(param.name); + paramElement.appendChild(nameElement); - const descElement = document.createElement('div'); - descElement.className = 'parameter-description'; - descElement.textContent = param.description || 'No description provided.'; + if (param.description) { + const descriptionElement = document.createElement('p'); + descriptionElement.className = 'parameter-description'; + descriptionElement.textContent = escapeHtml(param.description); + paramElement.appendChild(descriptionElement); + } - paramElement.appendChild(nameElement); - paramElement.appendChild(descElement); parametersList.appendChild(paramElement); }); diff --git a/packages/apps/mcp/src/public/style.css b/packages/apps/mcp/src/public/style.css index 7a457a815..46e972ade 100644 --- a/packages/apps/mcp/src/public/style.css +++ b/packages/apps/mcp/src/public/style.css @@ -142,17 +142,30 @@ body, html { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } +.tool-title-row { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + .tool-name { font-size: 1.25rem; - font-weight: 600; + font-weight: 700; color: #f3f4f6; - margin: 0 0 0.75rem 0; + margin-bottom: 0.5rem; +} + +.tool-version { + font-size: 0.9rem; + color: #9ca3af; + font-weight: 500; + white-space: nowrap; } .tool-description { - color: #d1d5db; - margin-bottom: 1.25rem; - line-height: 1.6; + color: #9ca3af; + margin-bottom: 1.5rem; + flex-grow: 1; } .parameters-title { @@ -184,15 +197,6 @@ body, html { margin-bottom: 0.25rem; } -.parameter-type { - font-size: 0.75rem; - color: #9ca3af; - background: rgba(75, 85, 99, 0.5); - padding: 0.2rem 0.5rem; - border-radius: 0.25rem; - margin-left: 0.5rem; -} - .parameter-description { color: #9ca3af; font-size: 0.875rem; @@ -201,14 +205,18 @@ body, html { } @media (max-width: 480px) { - .parameter-name-row { + .tool-title-row { flex-direction: column; align-items: flex-start; - gap: 0.5rem; + gap: 0.25rem; + } + + .tool-name { + font-size: 1.1rem; } - .parameter-type { - margin-left: 0; + .app-version { + font-size: 0.9rem; } } @@ -525,7 +533,6 @@ button, [type="button"], [type="reset"], [type="submit"] { .pkp-address-container { flex-direction: row; align-items: center; - padding: 1rem 1.25rem; } .pkp-address { diff --git a/packages/apps/mcp/src/registry.ts b/packages/apps/mcp/src/registry.ts new file mode 100644 index 000000000..5fa33fc97 --- /dev/null +++ b/packages/apps/mcp/src/registry.ts @@ -0,0 +1,17 @@ +import { nodeClient } from '@lit-protocol/vincent-registry-sdk'; +import { configureStore } from '@reduxjs/toolkit'; +import { fetchBaseQuery } from '@reduxjs/toolkit/query'; + +import { env } from './env/base'; + +const { VINCENT_REGISTRY_URL } = env; +const { vincentApiClientNode, setBaseQueryFn } = nodeClient; + +setBaseQueryFn(fetchBaseQuery({ baseUrl: VINCENT_REGISTRY_URL })); + +export const store = configureStore({ + reducer: { + vincentApi: vincentApiClientNode.reducer, + }, + middleware: (gdm) => gdm().concat(vincentApiClientNode.middleware), +}); diff --git a/packages/apps/mcp/src/transportManager.ts b/packages/apps/mcp/src/transportManager.ts index 32a07f91b..d13b8acac 100644 --- a/packages/apps/mcp/src/transportManager.ts +++ b/packages/apps/mcp/src/transportManager.ts @@ -1,46 +1,61 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import NodeCache from 'node-cache'; -import { env } from './env'; +import { env } from './env/http'; const { HTTP_TRANSPORT_TTL, HTTP_TRANSPORT_CLEAN_INTERVAL } = env; class TransportManager { - private readonly transports: { - [sessionId: string]: { - ttl: number; - transport: StreamableHTTPServerTransport; - }; - } = {}; + private readonly transportCache: NodeCache; constructor() { - setInterval(() => { - const now = Date.now(); - for (const [sessionId, { transport, ttl }] of Object.entries(this.transports)) { - if (now > ttl) { - transport.close().then(() => this.deleteTransport(sessionId)); + this.transportCache = new NodeCache({ + checkperiod: HTTP_TRANSPORT_CLEAN_INTERVAL / 1000, + deleteOnExpire: true, + stdTTL: HTTP_TRANSPORT_TTL / 1000, + useClones: false, // Store transport by reference + }); + + // Set up cleanup handler when items expire + this.transportCache.on( + 'expired', + async (key: string, transport: StreamableHTTPServerTransport) => { + try { + await transport.close(); + } catch (error) { + console.error(`Error closing transport for session ${key}:`, error); } - } - }, HTTP_TRANSPORT_CLEAN_INTERVAL); + }, + ); + } + + closeTransportManager() { + this.transportCache.close(); } addTransport(sessionId: string, transport: StreamableHTTPServerTransport) { - this.transports[sessionId] = { ttl: Date.now() + HTTP_TRANSPORT_TTL, transport }; + this.transportCache.set(sessionId, transport); } - getTransport(sessionId: string) { - const transportWithTtl = this.transports[sessionId]; - if (!transportWithTtl) { - return; + getTransport(sessionId: string): StreamableHTTPServerTransport | undefined { + const transport = this.transportCache.get(sessionId); + if (transport) { + // Reset TTL on access + this.transportCache.ttl(sessionId); } - - const { transport } = transportWithTtl; - this.transports[sessionId].ttl = Date.now() + HTTP_TRANSPORT_TTL; - return transport; } - deleteTransport(sessionId: string) { - delete this.transports[sessionId]; + async deleteTransport(sessionId: string): Promise { + const transport = this.transportCache.get(sessionId); + if (transport) { + try { + await transport.close(); + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + return this.transportCache.del(sessionId) > 0; } } diff --git a/packages/apps/mcp/vincent-app.example.json b/packages/apps/mcp/vincent-app.example.json index e3a3ebb85..19f7eff38 100644 --- a/packages/apps/mcp/vincent-app.example.json +++ b/packages/apps/mcp/vincent-app.example.json @@ -1,90 +1,62 @@ { - "id": "485", - "version": "5", + "id": "66034349", + "version": "1", "name": "Uniswap Swap", "description": "This app offers tools to approve ERC20 token allowances and perform swaps on uniswap", "tools": { - "QmWHK5KsJitDwW1zHRoiJQdQECASzSjjphp4Rg8YqB6BsX": { + "@lit-protocol/vincent-tool-erc20-approval": { "name": "erc20-approval", "description": "Allow an ERC20 token spending, up to a limit, to the Uniswap v3 Router contract. This is necessary to make trades on Uniswap.", - "parameters": [ - { - "name": "chainId", - "type": "number", + "parameters": { + "chainId": { "description": "The chain ID to execute the transaction on. For example: 8453 for Base." }, - { - "name": "rpcUrl", - "type": "string", + "rpcUrl": { "description": "The RPC URL to use for the transaction. Must support the chainId specified." }, - { - "name": "spenderAddress", - "type": "string", + "spenderAddress": { "description": "The spender address to approve. For example 0x2626664c2603336E57B271c5C0b26F421741e481 for the Uniswap v3 Swap Router contract on Base." }, - { - "name": "tokenAddress", - "type": "string", + "tokenAddress": { "description": "ERC20 Token address to approve. For example 0x4200000000000000000000000000000000000006 for WETH on Base." }, - { - "name": "tokenAmount", - "type": "number", + "tokenAmount": { "description": "Amount of tokenIn to approve. For example 0.00001 for 0.00001 WETH." }, - { - "name": "tokenDecimals", - "type": "number", + "tokenDecimals": { "description": "ERC20 Token to approve decimals. For example 18 for WETH on Base." } - ] + } }, - "QmSJWXQsmbp1Bbe7QKYugnJadnvGZJa1uf5y3gLvy6ZftU": { + "@lit-protocol/vincent-tool-uniswap-swap": { "name": "uniswap-swap", "description": "Executes a swap in Uniswap selling an specific amount of the input token to get another token. The necessary allowance for the input token must be approved for the Uniswap v3 Router contract.", - "parameters": [ - { - "name": "chainIdForUniswap", - "type": "number", + "parameters": { + "chainIdForUniswap": { "description": "The chain ID to execute the transaction on. For example: 8453 for Base." }, - { - "name": "rpcUrlForUniswap", - "type": "string", + "rpcUrlForUniswap": { "description": "An RPC endpoint for any chain that is supported by the @uniswap/sdk-core package. Must work for the chain specified in chainIdForUniswap" }, - { - "name": "ethRpcUrl", - "type": "string", + "ethRpcUrl": { "description": "An Ethereum Mainnet RPC Endpoint. This is used to check USD <> ETH prices via Chainlink." }, - { - "name": "tokenInAddress", - "type": "string", + "tokenInAddress": { "description": "ERC20 Token address to sell. For example 0x4200000000000000000000000000000000000006 for WETH on Base." }, - { - "name": "tokenInDecimals", - "type": "number", + "tokenInDecimals": { "description": "ERC20 Token to sell decimals. For example 18 for WETH on Base." }, - { - "name": "tokenInAmount", - "type": "number", + "tokenInAmount": { "description": "Amount of token to sell. For example 0.00001 for 0.00001 WETH. Must be greater than 0." }, - { - "name": "tokenOutAddress", - "type": "string", + "tokenOutAddress": { "description": "ERC20 Token address to buy. For example 0x50dA645f148798F68EF2d7dB7C1CB22A6819bb2C for SPX600 on Base." }, - { - "name": "tokenOutDecimals", - "type": "number", + "tokenOutDecimals": { "description": "ERC20 Token to buy decimals. For example 18 for WETH on Base." } - ] + } } } } diff --git a/packages/libs/mcp-sdk/README.md b/packages/libs/mcp-sdk/README.md index 9d9526ad7..ac46f09fa 100644 --- a/packages/libs/mcp-sdk/README.md +++ b/packages/libs/mcp-sdk/README.md @@ -21,6 +21,8 @@ The SDK provides tools to transform your Vincent application into an MCP server, ```typescript import { ethers } from 'ethers'; import { getVincentAppServer, VincentAppDef } from '@lit-protocol/vincent-mcp-sdk'; +import { bundledVincentTool as firstBundledVincentTool } from '@organization/first-tool-npm-pkg-name'; +import { bundledVincentTool as secondBundledVincentTool } from '@organization/second-tool-npm-pkg-name'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; // Create a signer for your delegatee account @@ -35,32 +37,30 @@ const vincentApp: VincentAppDef = { name: 'My Vincent App', version: '1', tools: { - 'ipfs-cid-of-tool-1': { + '@organization/first-tool-npm-pkg-name': { + version: '0.1.0', + bundledVincentTool: firstBundledVincentTool, name: 'sendMessage', description: 'Send a message to a user', - parameters: [ - { - name: 'recipient', - type: 'address', + parameters: { + recipient: { description: 'Ethereum address of the recipient', }, - { - name: 'message', - type: 'string', + message: { description: 'Message content to send', }, - ], + }, }, - 'ipfs-cid-of-tool-2': { + '@organization/second-tool-npm-pkg-name': { + version: '0.1.0', + bundledVincentTool: secondBundledVincentTool, name: 'checkBalance', description: 'Check the balance of an account', - parameters: [ - { - name: 'address', - type: 'address', + parameters: { + address: { description: 'Ethereum address to check', }, - ], + }, }, }, }; @@ -76,13 +76,15 @@ await mcpServer.connect(stdioTransport); ### How It Works -1. **Define Your Vincent Application**: Create a Vincent application definition with the tools you want to expose to LLMs. +1. **Install Tool packages from NPM**: Install tool packages from NPM using the package names and versions specified in your Vincent application definition. -2. **Create an MCP Server**: Use `getVincentAppServer()` to transform your Vincent application into an MCP server. +2. **Define Your Vincent Application**: Create a Vincent application definition with the tools you want to expose to LLMs. -3. **Connect to a Transport**: Connect your MCP server to a transport mechanism (stdio, HTTP, etc.) to allow LLMs to communicate with it. +3. **Create an MCP Server**: Use `getVincentAppServer()` to transform your Vincent application definition into an MCP server. -4. **LLM Interaction**: LLMs can now discover and use your Vincent tools through the MCP interface, executing them on behalf of authenticated users. +4. **Connect to a Transport**: Connect your MCP server to a transport mechanism (stdio, HTTP, etc.) to allow LLMs to communicate with it. + +5. **LLM Interaction**: LLMs can now discover and use your Vincent tools through the MCP interface, executing them on behalf of authenticated users. ### Benefits diff --git a/packages/libs/mcp-sdk/package.json b/packages/libs/mcp-sdk/package.json index 38da8c5c9..5e2bbf101 100644 --- a/packages/libs/mcp-sdk/package.json +++ b/packages/libs/mcp-sdk/package.json @@ -22,10 +22,8 @@ "sdk" ], "dependencies": { - "@lit-protocol/constants": "^7.2.0", - "@lit-protocol/lit-node-client": "^7.2.0", - "@lit-protocol/types": "7.2.0", "@lit-protocol/vincent-app-sdk": "workspace:*", + "@lit-protocol/vincent-tool-sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.12.1", "ethers": "5.8.0", "tslib": "^2.8.1", diff --git a/packages/libs/mcp-sdk/src/definitions.ts b/packages/libs/mcp-sdk/src/definitions.ts index a01d53992..4be13a94d 100644 --- a/packages/libs/mcp-sdk/src/definitions.ts +++ b/packages/libs/mcp-sdk/src/definitions.ts @@ -8,242 +8,27 @@ * @category Vincent MCP SDK */ -import { LitNodeClient } from '@lit-protocol/lit-node-client'; -import { generateVincentToolSessionSigs } from '@lit-protocol/vincent-app-sdk'; -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { - CallToolResult, - ServerRequest, - ServerNotification, -} from '@modelcontextprotocol/sdk/types.js'; -import { Signer } from 'ethers'; -import { z, type ZodRawShape } from 'zod'; + BundledVincentTool as _BundledVincentTool, + VincentTool, +} from '@lit-protocol/vincent-tool-sdk'; +import { z } from 'zod'; /** - * Supported parameter types for Vincent tool parameters + * Builds a unique tool name for use in the MCP. * - * These types define the valid parameter types that can be used in Vincent tool definitions. - * Each type has corresponding validation logic in the ZodSchemaMap. - */ -const ParameterType = [ - 'number', - 'number_array', - 'bool', - 'bool_array', - 'address', - 'address_array', - 'string', - 'string_array', - 'bytes', - 'bytes_array', -] as const; -const ParameterTypeEnum = z.enum(ParameterType); - -/** - * Type representing the supported parameter types for Vincent tool parameters - * @see {@link ParameterType} for the list of supported types - */ -export type ParameterType = z.infer; - -/** - * Mapping of parameter types to their corresponding Zod validation schemas - * - * This map provides validation logic for each supported parameter type. - * It is used by the buildMcpParamDefinitions function to create Zod schemas for tool parameters. - * - * @internal - */ -const ZodSchemaMap: Record = { - number: z.coerce.number(), - number_array: z.coerce.number().array(), - bool: z.boolean(), - bool_array: z.string().refine( - (val) => - val === '' || - val.split(',').every((item) => { - const trimmed = item.trim().toLowerCase(); - return ( - trimmed === '' || ['true', 'false', 'yes', 'no', '1', '0', 'y', 'n'].includes(trimmed) - ); - }), - { - message: 'Must be comma-separated boolean values or empty', - } - ), - address: z.string().regex(/^(0x[a-fA-F0-9]{40}|0x\.\.\.|)$/, { - message: 'Must be a valid Ethereum address, 0x..., or empty', - }), - address_array: z.string().refine( - (val) => - val === '' || - val.split(',').every((item) => { - const trimmed = item.trim(); - return trimmed === '' || trimmed === '0x...' || /^0x[a-fA-F0-9]{40}$/.test(trimmed); - }), - { - message: 'Must be comma-separated Ethereum addresses or empty', - } - ), - string: z.string(), - string_array: z.string(), - bytes: z.string(), - bytes_array: z.string(), -} as const; - -/** - * Builds Zod schema definitions for Vincent tool parameters - * - * This function takes an array of Vincent parameter definitions and creates a Zod schema - * that can be used to validate tool inputs. Each parameter is mapped to its corresponding - * validation schema from the ZodSchemaMap. + * The name is composed of the tool name, the Vincent application name, and the + * application version. The total length is capped at 64 characters to ensure + * compatibility with various systems. Invalid characters are replaced with hyphens. * - * @param params - Array of Vincent parameter definitions - * @param addDelegatorPkpAddress - Whether to add the delegator eth address as a param - * @returns A Zod schema shape that can be used to create a validation schema - * - * @example - * ```typescript - * const parameters: VincentParameter[] = [ - * { - * name: 'address', - * type: 'address', - * description: 'Ethereum address' - * }, - * { - * name: 'amount', - * type: 'number', - * description: 'Amount to transfer' - * } - * ]; - * - * const paramSchema = buildMcpParamDefinitions(parameters); - * const validationSchema = z.object(paramSchema); - * ``` + * @param vincentAppDef - The Vincent application definition. + * @param toolName - The name of the tool. + * @returns A unique, sanitized tool name. */ -export function buildMcpParamDefinitions( - params: VincentParameter[], - addDelegatorPkpAddress: boolean -): ZodRawShape { - const zodSchema = {} as ZodRawShape; - - // Add the delegator PKP Eth address as a param. Delegatee is using the MCP and must specify which delegator to execute tools for - if (addDelegatorPkpAddress) { - zodSchema['pkpEthAddress'] = z - .string() - .describe( - "The delegator's PKP address. The delegatee executes this tool on behalf of this delegator. Any PKP signing will be done by this delegator PKP. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045." - ); - } - - // Add the rest of the parameters - params.forEach((param) => { - let paramZodSchema = ZodSchemaMap[param.type] || z.string(); - if (param.optional) { - paramZodSchema = paramZodSchema.optional(); - } - zodSchema[param.name] = paramZodSchema.describe(param.description); - }); - - return zodSchema; -} - export function buildMcpToolName(vincentAppDef: VincentAppDef, toolName: string) { - return `${vincentAppDef.name.toLowerCase().replace(' ', '-')}-V${vincentAppDef.version}-${toolName}`; -} - -/** - * Creates a IPFS CID based Vincent Tool callback function to use in other contexts such as Agent Kits - * - * @param litNodeClient - The Lit Node client used to execute the tool - * @param delegateeSigner - The delegatee signer used to trigger the tool - * @param delegatorPkpEthAddress - The delegator to execute the tool in behalf of - * @param vincentToolDefWithIPFS - The tool definition with its IPFS CID - * @returns A callback function that executes the tool with the provided arguments - * @internal - */ -export function buildVincentToolCallback( - litNodeClient: LitNodeClient, - delegateeSigner: Signer, - delegatorPkpEthAddress: string | undefined, - vincentToolDefWithIPFS: VincentToolDefWithIPFS -) { - return async ( - args: ZodRawShape, - _extra?: RequestHandlerExtra - ): Promise<{ success: boolean; error?: string; result?: object }> => { - try { - const sessionSigs = await generateVincentToolSessionSigs({ - ethersSigner: delegateeSigner, - litNodeClient, - }); - const { pkpEthAddress, ...toolParams } = args; - const executeJsResponse = await litNodeClient.executeJs({ - ipfsId: vincentToolDefWithIPFS.ipfsCid, - sessionSigs, - jsParams: { - toolParams, - context: { - delegatorPkpEthAddress: delegatorPkpEthAddress || pkpEthAddress, - }, - }, - }); - - const executeJsSuccess = executeJsResponse.success || false; - if (!executeJsSuccess) { - throw new Error(JSON.stringify(executeJsResponse, null, 2)); - } - - const toolExecutionResponse = JSON.parse(executeJsResponse.response as string); - const { toolExecutionResult } = toolExecutionResponse; - const { success, error, result } = toolExecutionResult; - - return { success, error, result }; - } catch (e) { - const error = `Could not successfully execute Vincent Tool. Reason (${(e as Error).message})`; - return { success: false, error }; - } - }; -} - -/** - * Creates an MCP tool callback function for handling Vincent Tool execution requests - * It is basically an MCP specific version wrapper of buildVincentToolCallback - * - * @param litNodeClient - The Lit Node client used to execute the tool - * @param delegateeSigner - The delegatee signer used to trigger the tool - * @param delegatorPkpEthAddress - The delegator to execute the tool in behalf of - * @param vincentToolDefWithIPFS - The tool definition with its IPFS CID - * @returns A callback function that executes the tool with the provided arguments - * @internal - */ -export function buildMcpToolCallback( - litNodeClient: LitNodeClient, - delegateeSigner: Signer, - delegatorPkpEthAddress: string | undefined, - vincentToolDefWithIPFS: VincentToolDefWithIPFS -) { - const vincentToolCallback = buildVincentToolCallback( - litNodeClient, - delegateeSigner, - delegatorPkpEthAddress, - vincentToolDefWithIPFS - ); - - return async ( - args: ZodRawShape, - extra: RequestHandlerExtra - ): Promise => { - const { success, error, result } = await vincentToolCallback(args, extra); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ success, error, result }), - }, - ], - }; - }; + return `${toolName}/${vincentAppDef.name}/${vincentAppDef.version}` + .replace(/[^a-zA-Z0-9@&/_.-]/g, '-') + .substring(0, 64); } /** @@ -252,10 +37,7 @@ export function buildMcpToolCallback( * This schema defines the structure of a parameter in a Vincent tool. */ export const VincentParameterSchema = z.object({ - name: z.string(), - type: ParameterTypeEnum, - description: z.string(), - optional: z.boolean().default(false).optional(), + description: z.string().optional(), }); /** @@ -273,9 +55,9 @@ export type VincentParameter = z.infer; * This schema defines the structure of a tool in a Vincent application. */ export const VincentToolDefSchema = z.object({ - name: z.string(), - description: z.string(), - parameters: z.array(VincentParameterSchema), + name: z.string().optional(), + description: z.string().optional(), + parameters: z.record(VincentParameterSchema).optional(), }); /** @@ -288,13 +70,60 @@ export const VincentToolDefSchema = z.object({ export type VincentToolDef = z.infer; /** - * Type representing a tool in a Vincent application with its IPFS CID + * Type representing a bundled Vincent tool. This is a tool pkg that has been + * published to NPM. Check @lit-protocol/vincent-tool-sdk for more details + * + * @hidden + */ +export type BundledVincentTool = _BundledVincentTool< + VincentTool +>; + +/** + * Zod schema for validating a bundled Vincent tool. + * + * This schema defines the structure we need of a bundled Vincent tool. + * + * @hidden + */ +const BundledVincentToolSchema = z.custom( + (v): v is BundledVincentTool => + typeof v === 'object' && v !== null && 'vincentTool' in v && typeof v.vincentTool === 'object', + { message: 'Invalid BundledVincentTool, cannot create Vincent MCP Tool from it' } +); + +/** + * Zod schema for validating Vincent tool definitions published in an NPM package + * + * This schema defines the structure of a tool in a NPM package. + */ +export const VincentToolNpmSchema = VincentToolDefSchema.extend({ + version: z.string(), + bundledVincentTool: BundledVincentToolSchema, +}); + +/** + * Type representing a tool in a NPM package + * + * @property version - The version of the tool + */ +export type VincentToolNpm = z.infer; + +/** + * Zod schema for validating a collection of Vincent application tools. * - * This extends VincentToolDef with an additional ipfsCid property. + * This schema defines the structure for a record of tools, where each key + * is a tool identifier and the value is a valid VincentToolNpm object. + */ +export const VincentAppToolsSchema = z.record(VincentToolNpmSchema); + +/** + * Type representing a collection of tools in a Vincent application. * - * @property ipfsCid - The IPFS CID of the tool + * This is a record where keys are tool npm package names and + * values are `VincentToolNpm` objects. */ -export type VincentToolDefWithIPFS = VincentToolDef & { ipfsCid: string }; +export type VincentAppTools = z.infer; /** * Zod schema for validating Vincent application definitions @@ -304,9 +133,9 @@ export type VincentToolDefWithIPFS = VincentToolDef & { ipfsCid: string }; export const VincentAppDefSchema = z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), + description: z.string(), version: z.string(), - tools: z.record(VincentToolDefSchema), + tools: VincentAppToolsSchema, }); /** @@ -315,6 +144,6 @@ export const VincentAppDefSchema = z.object({ * @property id - The unique identifier of the application * @property name - The name of the application * @property version - The version of the application - * @property tools - A record of tools in the application, where the key is the IPFS CID of the tool + * @property tools - A record of tools in the application, where the key is the tool's npm package name */ export type VincentAppDef = z.infer; diff --git a/packages/libs/mcp-sdk/src/index.ts b/packages/libs/mcp-sdk/src/index.ts index bfc82b436..2ae10a34c 100644 --- a/packages/libs/mcp-sdk/src/index.ts +++ b/packages/libs/mcp-sdk/src/index.ts @@ -9,34 +9,36 @@ import { buildMcpToolName, - buildMcpParamDefinitions, - buildMcpToolCallback, - buildVincentToolCallback, VincentAppDefSchema, + VincentAppToolsSchema, + VincentParameterSchema, VincentToolDefSchema, + VincentToolNpmSchema, } from './definitions'; import type { - ParameterType, + BundledVincentTool, VincentAppDef, + VincentAppTools, VincentParameter, VincentToolDef, - VincentToolDefWithIPFS, + VincentToolNpm, } from './definitions'; import { getVincentAppServer } from './server'; export type { - ParameterType, + BundledVincentTool, VincentAppDef, + VincentAppTools, VincentParameter, VincentToolDef, - VincentToolDefWithIPFS, + VincentToolNpm, }; export { buildMcpToolName, - buildMcpParamDefinitions, - buildMcpToolCallback, - buildVincentToolCallback, getVincentAppServer, VincentAppDefSchema, + VincentAppToolsSchema, + VincentParameterSchema, VincentToolDefSchema, + VincentToolNpmSchema, }; diff --git a/packages/libs/mcp-sdk/src/server.ts b/packages/libs/mcp-sdk/src/server.ts index 524bd0148..43881bf95 100644 --- a/packages/libs/mcp-sdk/src/server.ts +++ b/packages/libs/mcp-sdk/src/server.ts @@ -6,72 +6,111 @@ * @module mcp/server * @category Vincent MCP SDK */ - -import { LIT_NETWORK } from '@lit-protocol/constants'; -import { LitNodeClient } from '@lit-protocol/lit-node-client'; -import type { LitNodeClientConfig } from '@lit-protocol/types'; -import { utils } from '@lit-protocol/vincent-app-sdk'; -import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; -import type { ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { getVincentToolClient, utils } from '@lit-protocol/vincent-app-sdk'; +import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + CallToolResult, + ServerRequest, + ServerNotification, +} from '@modelcontextprotocol/sdk/types.js'; import { Signer } from 'ethers'; +import { type ZodRawShape } from 'zod'; -import { - buildMcpToolName, - buildMcpParamDefinitions, - buildMcpToolCallback, - VincentAppDef, - VincentAppDefSchema, - VincentToolDefWithIPFS, -} from './definitions'; +import { buildMcpToolName, VincentAppDef, VincentAppDefSchema } from './definitions'; const { getDelegatorsAgentPkpAddresses } = utils; +/** + * Configuration for a Vincent MCP server regarding its delegation mode. + * + * @property {Signer} delegateeSigner - The signer for the delegatee, used to execute tools. + * @property {string | undefined} delegatorPkpEthAddress - The PKP Ethereum address of the delegator. If undefined, the server operates in delegatee-only mode. + */ export interface DelegationMcpServerConfig { delegateeSigner: Signer; delegatorPkpEthAddress: string | undefined; } -export interface LitServerOptions extends ServerOptions { - litNodeClientOptions: LitNodeClientConfig; -} - -export class VincentMcpServer extends McpServer { - litNodeClient: LitNodeClient; +/** + * Registers Vincent tools with an MCP server. + * + * This function iterates through the tools defined in the Vincent application definition, + * dynamically imports each tool's package, and registers it with the provided MCP server. + * It configures each tool to be executed with the delegatee's signer and handles parameter descriptions. + * + * @param {VincentAppDef} vincentAppDef - The Vincent application definition containing the tools to register. + * @param {McpServer} server - The MCP server instance to register the tools with. + * @param {DelegationMcpServerConfig} config - The server configuration, including the delegatee signer. + * @private + * @hidden + */ +async function registerVincentTools( + vincentAppDef: VincentAppDef, + server: McpServer, + config: DelegationMcpServerConfig +) { + const { delegateeSigner, delegatorPkpEthAddress } = config; - constructor(serverInfo: Implementation, options?: LitServerOptions) { - super(serverInfo, options); + for (const vincentAppToolDef of Object.values(vincentAppDef.tools)) { + const bundledVincentTool = vincentAppToolDef.bundledVincentTool; + const { + vincentTool: { packageName, toolDescription, toolParamsSchema }, + } = bundledVincentTool; - const litNodeClientOptions = options?.litNodeClientOptions || {}; - this.litNodeClient = new LitNodeClient({ - debug: true, - litNetwork: LIT_NETWORK.Datil, - ...litNodeClientOptions, + const toolClient = getVincentToolClient({ + ethersSigner: delegateeSigner, + bundledVincentTool: bundledVincentTool, }); - } - override async connect(transport: Transport): Promise { - await super.connect(transport); + // Add available descriptions to each param + const toolParamsSchemaShape = { ...toolParamsSchema.shape }; + Object.entries(vincentAppToolDef.parameters || {}).forEach(([key, param]) => { + if (param.description) { + toolParamsSchemaShape[key] = toolParamsSchemaShape[key].describe(param.description); + } + }); - await this.litNodeClient.connect(); - } + server.registerTool( + buildMcpToolName(vincentAppDef, vincentAppToolDef.name || packageName), + { + description: vincentAppToolDef.description || toolDescription || '', // First versions on the tool SDK did not have a description + inputSchema: toolParamsSchemaShape, + }, + async ( + args: ZodRawShape, + extra: RequestHandlerExtra + ): Promise => { + const precheckResult = await toolClient.precheck(args, { + delegatorPkpEthAddress: delegatorPkpEthAddress!, + }); + if ('error' in precheckResult || !precheckResult.success) { + throw new Error( + JSON.stringify( + { + success: precheckResult.success, + error: precheckResult.error, + result: precheckResult.result, + }, + null, + 2 + ) + ); + } - override async close(): Promise { - await this.litNodeClient.disconnect(); - } + const executeResult = await toolClient.execute(args, { + delegatorPkpEthAddress: delegatorPkpEthAddress!, + }); - vincentTool( - name: string, - toolData: VincentToolDefWithIPFS, - delegateeSigner: Signer, - delegatorPkpEthAddress?: string - ) { - this.tool( - name, - toolData.description, - buildMcpParamDefinitions(toolData.parameters, !delegatorPkpEthAddress), - buildMcpToolCallback(this.litNodeClient, delegateeSigner, delegatorPkpEthAddress, toolData) + return { + content: [ + { + type: 'text', + text: JSON.stringify(executeResult), + }, + ], + }; + } ); } } @@ -79,12 +118,15 @@ export class VincentMcpServer extends McpServer { /** * Creates an MCP server for a Vincent application * - * This function configures an MCP server with the tools defined in the Vincent application definition. - * Each tool is registered with the server and configured to use the provided delegatee signer for execution. + * This function configures an MCP server based on the Vincent application definition provided. + * Each Vincent tool is registered with the server and configured to use the provided delegatee signer for execution. + * Extra tools to get delegator and app info are added. + * + * Tool packages MUST be installed before calling this function as it will try to import them on demand. * * Check (MCP Typescript SDK docs)[https://github.com/modelcontextprotocol/typescript-sdk] for more details on MCP server definition. * - * @param vincentAppDefinition - The Vincent application definition containing the tools to register + * @param {VincentAppDef} vincentAppDefinition - The Vincent application definition containing the tools to register * @param {DelegationMcpServerConfig} config - The server configuration * @returns A configured MCP server instance * @@ -105,13 +147,12 @@ export class VincentMcpServer extends McpServer { * name: 'My Vincent App', * description: 'A Vincent application that executes tools for its delegators', * tools: { - * 'QmIpfsCid1': { + * '@organization/some_tool': { * name: 'myTool', * description: 'A tool that does something', * parameters: [ * { * name: 'param1', - * type: 'string', * description: 'A parameter that is used in the tool to do something' * } * ] @@ -132,36 +173,54 @@ export async function getVincentAppServer( config: DelegationMcpServerConfig ): Promise { const { delegatorPkpEthAddress } = config; - const _vincentAppDefinition = VincentAppDefSchema.parse(vincentAppDefinition); + const vincentAppDef = VincentAppDefSchema.parse(vincentAppDefinition); - const server = new VincentMcpServer({ - name: _vincentAppDefinition.name, - version: _vincentAppDefinition.version, + const server = new McpServer({ + name: vincentAppDef.name, + version: vincentAppDef.version, }); if (delegatorPkpEthAddress) { - server.tool( - buildMcpToolName(_vincentAppDefinition, 'get-current-agent-pkp-address'), - `Tool to get your agent pkp eth address in use for the ${_vincentAppDefinition.name} Vincent App MCP.`, - async () => { - return { - content: [ - { - type: 'text', - text: delegatorPkpEthAddress, - }, - ], - }; - } + // Add as resource and tool to maximize compatibility (some LLM clients may not support resources) + server.registerResource( + buildMcpToolName(vincentAppDef, 'current-agent-eth-address'), + 'agent://current/eth-address', + { + description: `Resource to get current agent eth address in use for Vincent App ${vincentAppDef.id}/${vincentAppDef.version}.`, + }, + async (uri: URL) => ({ + contents: [ + { + uri: uri.href, + text: delegatorPkpEthAddress, + }, + ], + }) + ); + server.registerTool( + buildMcpToolName(vincentAppDef, 'get-current-agent-eth-address'), + { + description: `Tool to get current agent eth address in use for Vincent App ${vincentAppDef.id}/${vincentAppDef.version}.`, + }, + async () => ({ + content: [ + { + type: 'text', + text: delegatorPkpEthAddress, + }, + ], + }) ); } else { // In delegatee mode (no delegator), user has to be able to fetch its delegators and select which one to operate on behalf of - server.tool( - buildMcpToolName(_vincentAppDefinition, 'get-delegators-eth-addresses'), - `Tool to get the delegators pkp Eth addresses for the ${_vincentAppDefinition.name} Vincent App.`, + server.registerTool( + buildMcpToolName(vincentAppDef, 'get-delegators-eth-addresses'), + { + description: `Tool to get the delegators pkp Eth addresses for the ${vincentAppDef.name} Vincent App.`, + }, async () => { - const appId = parseInt(_vincentAppDefinition.id, 10); - const appVersion = parseInt(_vincentAppDefinition.version, 10); + const appId = parseInt(vincentAppDef.id, 10); + const appVersion = parseInt(vincentAppDef.version, 10); const delegatorsPkpEthAddresses = await getDelegatorsAgentPkpAddresses(appId, appVersion); @@ -177,36 +236,45 @@ export async function getVincentAppServer( ); } - server.tool( - buildMcpToolName(_vincentAppDefinition, 'get-current-vincent-app-info'), - `Tool to get the ${_vincentAppDefinition.name} Vincent App info.`, - async () => { - const appInfo = { - id: _vincentAppDefinition.id, - name: _vincentAppDefinition.name, - version: _vincentAppDefinition.version, - description: _vincentAppDefinition.description, - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(appInfo), - }, - ], - }; - } + const appInfo = { + id: vincentAppDef.id, + name: vincentAppDef.name, + version: vincentAppDef.version, + description: vincentAppDef.description, + }; + // Add as resource and tool to maximize compatibility (some LLM clients may not support resources) + server.registerResource( + buildMcpToolName(vincentAppDef, 'current-vincent-app-info'), + `app://${vincentAppDef.id}/${vincentAppDef.version}/info`, + { + description: `Resource to get the Vincent App ${vincentAppDef.id}/${vincentAppDef.version} info.`, + }, + async (uri: URL) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify(appInfo), + }, + ], + }) + ); + server.registerTool( + buildMcpToolName(vincentAppDef, 'get-current-vincent-app-info'), + { + description: `Tool to get the Vincent App ${vincentAppDef.id}/${vincentAppDef.version} info.`, + }, + async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify(appInfo), + }, + ], + }) ); - Object.entries(_vincentAppDefinition.tools).forEach(([toolIpfsCid, tool]) => { - server.vincentTool( - buildMcpToolName(_vincentAppDefinition, tool.name), - { ipfsCid: toolIpfsCid, ...tool }, - config.delegateeSigner, - config.delegatorPkpEthAddress - ); - }); + // Fetch and install tool packages, then load them as Vincent MCP Tools + await registerVincentTools(vincentAppDef, server, config); return server; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1e467d2f..4d4648d26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,9 +403,15 @@ importers: '@lit-protocol/vincent-mcp-sdk': specifier: workspace:* version: link:../../libs/mcp-sdk + '@lit-protocol/vincent-registry-sdk': + specifier: workspace:* + version: link:../../libs/registry-sdk '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.13.0 + '@reduxjs/toolkit': + specifier: ^2.8.2 + version: 2.8.2(react-redux@9.2.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.8(typescript@5.8.3)(zod@3.25.64) @@ -418,6 +424,12 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + node-cache: + specifier: ^5.1.2 + version: 5.1.2 + npx-import: + specifier: ^1.1.4 + version: 1.1.4 siwe: specifier: ^3.0.0 version: 3.0.0(ethers@5.8.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -804,18 +816,12 @@ importers: packages/libs/mcp-sdk: dependencies: - '@lit-protocol/constants': - specifier: ^7.2.0 - version: 7.2.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@lit-protocol/lit-node-client': - specifier: ^7.2.0 - version: 7.2.0(@walletconnect/modal@2.7.0)(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@lit-protocol/types': - specifier: 7.2.0 - version: 7.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@lit-protocol/vincent-app-sdk': specifier: workspace:* version: link:../app-sdk + '@lit-protocol/vincent-tool-sdk': + specifier: workspace:* + version: link:../tool-sdk '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.13.0 @@ -5046,6 +5052,9 @@ packages: resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} + builtins@5.1.0: + resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + bun@1.2.16: resolution: {integrity: sha512-sjZH6rr1P6yu44+XPA8r+ZojwmK9Kbz9lO6KAA/4HRIupdpC31k7b93crLBm19wEYmd6f2+3+57/7tbOcmHbGg==} os: [darwin, linux, win32] @@ -5222,6 +5231,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -6258,6 +6271,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@6.1.0: + resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -6809,6 +6826,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@3.0.1: + resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} + engines: {node: '>=12.20.0'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -8168,6 +8189,10 @@ packages: node-addon-api@2.0.2: resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -8266,6 +8291,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npx-import@1.1.4: + resolution: {integrity: sha512-3ShymTWOgqGyNlh5lMJAejLuIv3W1K3fbI5Ewc6YErZU3Sp0PqsNs8UIU1O8z5+KVl/Du5ag56Gza9vdorGEoA==} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -8499,6 +8527,9 @@ packages: resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} engines: {node: '>=6'} + parse-package-name@1.0.0: + resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} @@ -10393,6 +10424,10 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + validate-npm-package-name@4.0.0: + resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -17879,6 +17914,10 @@ snapshots: dependencies: node-gyp-build: 4.8.4 + builtins@5.1.0: + dependencies: + semver: 7.7.2 + bun@1.2.16: optionalDependencies: '@oven/bun-darwin-aarch64': 1.2.16 @@ -18091,6 +18130,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + clsx@1.2.1: {} clsx@2.1.1: {} @@ -19339,6 +19380,18 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@6.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 3.0.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -20002,6 +20055,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@3.0.1: {} + human-signals@5.0.0: {} husky@9.1.7: {} @@ -21765,6 +21820,10 @@ snapshots: node-addon-api@2.0.2: {} + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + node-fetch-h2@2.3.0: dependencies: http2-client: 1.3.5 @@ -21856,6 +21915,13 @@ snapshots: dependencies: path-key: 4.0.0 + npx-import@1.1.4: + dependencies: + execa: 6.1.0 + parse-package-name: 1.0.0 + semver: 7.7.2 + validate-npm-package-name: 4.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -22187,6 +22253,8 @@ snapshots: parse-ms@2.1.0: {} + parse-package-name@1.0.0: {} + parse-passwd@1.0.0: {} parse5@7.3.0: @@ -24276,6 +24344,10 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + validate-npm-package-name@4.0.0: + dependencies: + builtins: 5.1.0 + validate-npm-package-name@5.0.1: {} validate-npm-package-name@6.0.1: {}