From 16e1f460fe18e41f9ed217e235af9cf2580a8fb9 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Thu, 29 May 2025 17:19:59 +0200 Subject: [PATCH] feat: add a centralized mcp server with support for multiple vincent app definitions based on the server path and using authorization header as the pk management system --- packages/mcp/package.json | 1 + packages/mcp/src/appDefinitions/196/1.json | 75 ++++++++++++ packages/mcp/src/centralized.ts | 135 +++++++++++++++++++++ packages/mcp/src/delegatees.ts | 11 ++ packages/mcp/src/env.ts | 2 +- packages/mcp/src/http.ts | 9 +- packages/mcp/src/registry.ts | 11 ++ packages/mcp/src/server.ts | 18 ++- packages/mcp/src/stdio.ts | 7 +- 9 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 packages/mcp/src/appDefinitions/196/1.json create mode 100644 packages/mcp/src/centralized.ts create mode 100644 packages/mcp/src/delegatees.ts create mode 100644 packages/mcp/src/registry.ts diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 37696acc6..6ec776c72 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -33,6 +33,7 @@ "build:watch": "tsc --watch", "dev:stdio": "tsx src/stdio.ts", "dev:http": "tsx watch --env-file=.env src/http.ts", + "dev:centralized": "tsx watch --env-file=.env src/centralized.ts", "inspector": "npx @modelcontextprotocol/inspector", "test": "echo \"Error: no test specified\" && exit 1", "mintRlI": "tsx --env-file=.env ./src/bin/mintRLINft.ts" diff --git a/packages/mcp/src/appDefinitions/196/1.json b/packages/mcp/src/appDefinitions/196/1.json new file mode 100644 index 000000000..bbbfe2e80 --- /dev/null +++ b/packages/mcp/src/appDefinitions/196/1.json @@ -0,0 +1,75 @@ +{ + "id": "196", + "version": "1", + "name": "Uniswap Swap", + "description": "This app offers tools to approve ERC20 token allowances and perform swaps on uniswap", + "tools": { + "QmPZ46EiurxMb7DmE9McFyzHfg2B6ZGEERui2tnNNX7cky": { + "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": "string", + "description": "The chain ID to execute the transaction on. For example: 8453 for Base." + }, + { + "name": "tokenIn", + "type": "string", + "description": "ERC20 Token address to approve. For example 0x4200000000000000000000000000000000000006 for WETH on Base." + }, + { + "name": "amountIn", + "type": "string", + "description": "Amount of tokenIn to approve. For example 0.00001 for 0.00001 WETH." + }, + { + "name": "pkpEthAddress", + "type": "string", + "description": "The delegator's PKP address that will execute the swap. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045." + }, + { + "name": "rpcUrl", + "type": "string", + "description": "The RPC URL to use for the transaction" + } + ] + }, + "QmZbh52JYnutuFURnpwfywfiiHuFoJpqFyFzNiMtbiDNkK": { + "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": "chainId", + "type": "string", + "description": "The chain ID to execute the transaction on. For example: 8453 for Base." + }, + { + "name": "tokenIn", + "type": "string", + "description": "ERC20 Token address to sell. For example 0x4200000000000000000000000000000000000006 for WETH on Base." + }, + { + "name": "amountIn", + "type": "string", + "description": "Amount of tokenIn to sell. For example 0.00001 for 0.00001 WETH." + }, + { + "name": "tokenOut", + "type": "string", + "description": "ERC20 Token address to buy. For example 0x50dA645f148798F68EF2d7dB7C1CB22A6819bb2C for SPX600 on Base." + }, + { + "name": "pkpEthAddress", + "type": "string", + "description": "The delegator's PKP address that will execute the swap. For example 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045." + }, + { + "name": "rpcUrl", + "type": "string", + "description": "The RPC URL to use for the transaction" + } + ] + } + } +} diff --git a/packages/mcp/src/centralized.ts b/packages/mcp/src/centralized.ts new file mode 100644 index 000000000..3edf7bf50 --- /dev/null +++ b/packages/mcp/src/centralized.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Bundled HTTP server implementation for Vincent MCP + * + * This module provides an HTTP server implementation for the Model Context Protocol (MCP) + * using Express. It creates a streamable HTTP server that can handle MCP requests + * and maintain session state across multiple requests. + * + * The server loads a Vincent application definition from a JSON file on demand and creates + * an MCP server with extended capabilities for that application. It then exposes + * the server via HTTP endpoints that follow the MCP protocol. + * + * @module http + * @category Vincent MCP + */ + +import { randomUUID } from 'node:crypto'; + +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import express, { Request, Response } from 'express'; + +import { getVincentDelegateeSigner } from './delegatees'; +import { env } from './env'; +import { getVincentAppDef } from './registry'; +import { getServer } from './server'; + +const MCP_URL = '/:appId/:appVersion/mcp'; + +const { HTTP_PORT } = env; + +const app = express(); +app.use(express.json()); + +// In-memory store for transports +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +app.post(MCP_URL, async (req: Request, res: Response) => { + try { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports[sessionId] = transport; + }, + }); + + // Cleanup transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + + const authorization = (req.headers['authorization'] as string) || ''; + const [, delegateePrivateKey] = authorization.split(' '); + if (!delegateePrivateKey) { + throw new Error('No delegatee private key provided'); + } + + const { appId, appVersion } = req.params; + const delegateeSigner = getVincentDelegateeSigner(delegateePrivateKey); + const vincentAppDef = await getVincentAppDef(appId, appVersion); + if (!delegateeSigner) { + throw new Error( + `No delegatee signer found for app ${appId}. App must be registered with a delegatee private key before it can be used.`, + ); + } + + const server = getServer(vincentAppDef, delegateeSigner); + await server.connect(transport); + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: (error as Error).message || 'Internal Server Error', + }, + id: null, + }); + } +}); + +/** + * Handles GET and DELETE requests for MCP sessions + * + * This function processes requests that require an existing session, + * such as GET requests for streaming responses or DELETE requests to + * terminate a session. It validates the session ID and delegates the + * request handling to the appropriate transport. + * + * @param req - The Express request object + * @param res - The Express response object + * @internal + */ +const handleSessionRequest = async (req: express.Request, res: express.Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}; + +app.get(MCP_URL, handleSessionRequest); +app.delete(MCP_URL, handleSessionRequest); + +app.listen(HTTP_PORT, () => { + console.log(`Centralized Vincent MCP Server listening on port ${HTTP_PORT}`); +}); diff --git a/packages/mcp/src/delegatees.ts b/packages/mcp/src/delegatees.ts new file mode 100644 index 000000000..43b6143cd --- /dev/null +++ b/packages/mcp/src/delegatees.ts @@ -0,0 +1,11 @@ +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; +import { ethers } from 'ethers'; + +export function getVincentDelegateeSigner(delegateePrivateKey: string) { + const delegateeSigner = new ethers.Wallet( + delegateePrivateKey, + new ethers.providers.StaticJsonRpcProvider(LIT_EVM_CHAINS.yellowstone.rpcUrls[0]), + ); + + return delegateeSigner; +} diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts index a49d582b3..d0a30d50c 100644 --- a/packages/mcp/src/env.ts +++ b/packages/mcp/src/env.ts @@ -24,7 +24,7 @@ export const env = createEnv({ HTTP_PORT: z.coerce.number().default(3000), PUBKEY_ROUTER_DATIL_CONTRACT: z.string(), VINCENT_APP_JSON_DEFINITION: z.string(), - VINCENT_DELEGATEE_PRIVATE_KEY: z.string(), VINCENT_DATIL_CONTRACT: z.string(), + VINCENT_DELEGATEE_PRIVATE_KEY: z.string(), }, }); diff --git a/packages/mcp/src/http.ts b/packages/mcp/src/http.ts index 333702ebe..8801ea6a6 100644 --- a/packages/mcp/src/http.ts +++ b/packages/mcp/src/http.ts @@ -22,10 +22,11 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import express, { Request, Response } from 'express'; +import { getVincentDelegateeSigner } from './delegatees'; import { env } from './env'; import { getServer } from './server'; -const { HTTP_PORT, VINCENT_APP_JSON_DEFINITION } = env; +const { HTTP_PORT, VINCENT_APP_JSON_DEFINITION, VINCENT_DELEGATEE_PRIVATE_KEY } = env; const { VincentAppDefSchema } = mcp; const vincentAppJson = fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }); @@ -61,7 +62,9 @@ app.post('/mcp', async (req: Request, res: Response) => { } }; - const server = getServer(vincentAppDef); + // In local HTTP configuration, the signer is defined with VINCENT_DELEGATEE_PRIVATE_KEY env variable + const delegateeSigner = getVincentDelegateeSigner(VINCENT_DELEGATEE_PRIVATE_KEY); + const server = getServer(vincentAppDef, delegateeSigner); await server.connect(transport); } else { // Invalid request @@ -84,7 +87,7 @@ app.post('/mcp', async (req: Request, res: Response) => { jsonrpc: '2.0', error: { code: -32000, - message: 'Internal Server Error', + message: (error as Error).message || 'Internal Server Error', }, id: null, }); diff --git a/packages/mcp/src/registry.ts b/packages/mcp/src/registry.ts new file mode 100644 index 000000000..34ccda630 --- /dev/null +++ b/packages/mcp/src/registry.ts @@ -0,0 +1,11 @@ +import { mcp } from '@lit-protocol/vincent-sdk'; +import fs from 'node:fs/promises'; + +const { VincentAppDefSchema } = mcp; + +export async function getVincentAppDef(appId: string, appVersion: string) { + const vincentAppDefLocation = `${process.cwd()}/src/appDefinitions/${appId}/${appVersion}.json`; + + const vincentAppJson = await fs.readFile(vincentAppDefLocation, { encoding: 'utf8' }); + return VincentAppDefSchema.parse(JSON.parse(vincentAppJson)); +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 0b3418b2f..ca92e6633 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -9,27 +9,25 @@ * @category Vincent MCP */ -import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; import { mcp } from '@lit-protocol/vincent-sdk'; import { ethers } from 'ethers'; -import { env } from './env'; import { extendVincentServer } from './extensions'; -const { VINCENT_DELEGATEE_PRIVATE_KEY } = env; const { getVincentAppServer } = mcp; /** * Creates an extended MCP server for a Vincent application * * This function creates an MCP server for a Vincent application and extends it - * with additional functionality. It uses the delegatee private key from the + * with additional functionality. It assigns a signer using the delegatee private key from the * environment to create a signer that can execute Vincent tools on behalf of users. * * The server is created using the Vincent SDK's `getVincentAppServer` function * and then extended with additional capabilities using the `extendVincentServer` function. * * @param vincentAppDef - The Vincent application definition containing the tools to register + * @param delegateeSigner - The Ethereum signer used to execute Vincent tools * @returns A configured and extended MCP server instance * * @example @@ -48,20 +46,18 @@ const { getVincentAppServer } = mcp; * } * }; * + * // Create a signer + * const delegateeSigner = getVincentDelegateeSigner(VINCENT_DELEGATEE_PRIVATE_KEY); + * * // Create the extended MCP server - * const server = getServer(appDef); + * const server = getServer(appDef, delegateeSigner); * * // Add transport to expose the server * const stdio = new StdioServerTransport(); * await server.connect(stdio); * ``` */ -export function getServer(vincentAppDef: mcp.VincentAppDef) { - const delegateeSigner = new ethers.Wallet( - VINCENT_DELEGATEE_PRIVATE_KEY, - new ethers.providers.StaticJsonRpcProvider(LIT_EVM_CHAINS.yellowstone.rpcUrls[0]), - ); - +export function getServer(vincentAppDef: mcp.VincentAppDef, delegateeSigner: ethers.Signer) { const server = getVincentAppServer(delegateeSigner, vincentAppDef); extendVincentServer(server, vincentAppDef, delegateeSigner); diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts index 1ac4e35fa..3b358e58b 100644 --- a/packages/mcp/src/stdio.ts +++ b/packages/mcp/src/stdio.ts @@ -22,10 +22,11 @@ import fs from 'node:fs'; import { mcp } from '@lit-protocol/vincent-sdk'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { getVincentDelegateeSigner } from './delegatees'; import { env } from './env'; import { getServer } from './server'; -const { VINCENT_APP_JSON_DEFINITION } = env; +const { VINCENT_APP_JSON_DEFINITION, VINCENT_DELEGATEE_PRIVATE_KEY } = env; const { VincentAppDefSchema } = mcp; /** @@ -45,7 +46,9 @@ async function main() { const vincentAppJson = fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }); const vincentAppDef = VincentAppDefSchema.parse(JSON.parse(vincentAppJson)); - const server = getServer(vincentAppDef); + // In local STDIO configuration, the signer is defined with VINCENT_DELEGATEE_PRIVATE_KEY env variable + const delegateeSigner = getVincentDelegateeSigner(VINCENT_DELEGATEE_PRIVATE_KEY); + const server = getServer(vincentAppDef, delegateeSigner); await server.connect(stdioTransport); console.error('Vincent MCP Server running in stdio mode'); // console.log is used for messaging the parent process }