-
Notifications
You must be signed in to change notification settings - Fork 7
Feature/drel 817 migrate mcp from ipfc cids to npm pkg names #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 12 commits
41a20bc
9cda182
b24254d
4774cea
3e8d8cf
10a1052
4ccb43a
b6f7368
c1e3897
8c16a12
a6d838a
2480c2c
72221c4
5ce2a6d
1f620fd
74da7c6
e168934
b374d76
2ec989c
de34b8c
1b1ea11
6102180
5e70421
3a56bcb
bd08339
5bcd28f
577ecdb
4e84739
a5680f6
eccf6ed
5303ce0
b298aeb
1a6d82f
c49d29f
bc57d89
02d8822
99e2d6d
a9040ab
58d1109
5033694
51f1340
70d6bac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| import { exec } from 'node:child_process'; | ||
| import fs from 'node:fs'; | ||
|
|
||
| import { VincentAppDefSchema, VincentToolNpmSchema } from '@lit-protocol/vincent-mcp-sdk'; | ||
| import type { VincentAppDef, VincentAppTools } from '@lit-protocol/vincent-mcp-sdk'; | ||
| import { Wallet } from 'ethers'; | ||
| import { generateNonce, SiweMessage } from 'siwe'; | ||
| import which from 'which'; | ||
| import { z, type ZodAny, ZodObject } from 'zod'; | ||
|
|
||
| import { env } from './env'; | ||
|
|
||
| const { VINCENT_APP_ID, VINCENT_APP_JSON_DEFINITION, VINCENT_REGISTRY_URL } = env; | ||
|
|
||
| interface RegistryApp { | ||
| appId: number; | ||
| activeVersion: number; | ||
| name: string; | ||
| description: string; | ||
| redirectUris: string[]; | ||
| deploymentStatus: 'dev' | 'test' | 'prod'; | ||
| managerAddress: string; | ||
| isDeleted: boolean; | ||
| } | ||
|
|
||
| interface RegistryAppVersionTool { | ||
| appId: number; | ||
| appVersion: number; | ||
| toolPackageName: string; | ||
| toolVersion: string; | ||
| isDeleted: boolean; | ||
| } | ||
|
|
||
| const JsonVincentAppToolsSchema = z.record(VincentToolNpmSchema.omit({ version: true })); | ||
| const JsonVincentAppSchema = VincentAppDefSchema.extend({ | ||
| tools: JsonVincentAppToolsSchema, | ||
| }).partial(); | ||
|
|
||
| type JsonVincentAppTools = z.infer<typeof JsonVincentAppToolsSchema>; | ||
|
|
||
| async function installToolPackages(tools: VincentAppTools) { | ||
| return await new Promise<void>((resolve, reject) => { | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const packagesToInstall = Object.entries(tools).map(([toolNpmName, pkgInfo]) => { | ||
| return `${toolNpmName}@${pkgInfo.version}`; | ||
| }); | ||
|
|
||
| console.log(`Installing tool packages ${packagesToInstall.join(', ')}...`); | ||
| // When running in the Vincent repo ecosystem, pnpm must be used to avoid conflicts with nx and pnpm configurations | ||
| // On `npx` commands, pnpm might not even be available. We fall back to having `npm` in the running machine (having `npx` implies that) | ||
| const mgr = which.sync('pnpm', { nothrow: true }) ? 'pnpm' : 'npm'; | ||
| const command = | ||
| mgr === 'npm' | ||
| ? `npm i ${packagesToInstall.join(' ')} --no-save --production --ignore-scripts` | ||
| : `pnpm i ${packagesToInstall.join(' ')} --save-exact --no-lockfile --ignore-scripts`; | ||
| exec(command, (error, stdout, stderr) => { | ||
| if (error) { | ||
| console.error(error); | ||
| reject(error); | ||
| return; | ||
| } | ||
| // stderr has the debugger logs so it seems to fail when executing with the debugger | ||
| // if (stderr) { | ||
| // console.error(stderr); | ||
| // reject(stderr); | ||
| // return; | ||
| // } | ||
|
|
||
| console.log(`Successfully installed ${packagesToInstall.join(', ')}`); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function registerVincentTools(tools: VincentAppTools): Promise<VincentAppTools> { | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const toolsObject: VincentAppTools = {}; | ||
| for (const [toolPackage, toolData] of Object.entries(tools)) { | ||
| console.log(`Loading tool package ${toolPackage}...`); | ||
| const tool = require(toolPackage); // import cannot find the pkgs just installed as they were not there when the process started | ||
| console.log(`Successfully loaded tool package ${toolPackage}`); | ||
|
|
||
| const bundledVincentTool = tool.bundledVincentTool; | ||
| const { vincentTool } = bundledVincentTool; | ||
| const { toolParamsSchema } = vincentTool; | ||
| const paramsSchema = toolParamsSchema.shape as ZodObject<any>; | ||
|
|
||
| const parameters = Object.entries(paramsSchema).map(([key, value]) => { | ||
| const parameterSchema = value as ZodAny; | ||
| const parameter = { | ||
| name: key, | ||
| ...(parameterSchema.description ? { description: parameterSchema.description } : {}), | ||
| }; | ||
|
|
||
| return parameter; | ||
| }); | ||
|
|
||
| // Add name, description | ||
| toolsObject[toolPackage] = { | ||
| name: toolPackage, | ||
| // description: 'TODO', | ||
| parameters, | ||
| ...toolData, | ||
| }; | ||
| } | ||
|
|
||
| return toolsObject; | ||
| } | ||
|
|
||
| async function getAppDataFromRegistry(appId: string): Promise<VincentAppDef> { | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const delegateeSigner = Wallet.createRandom(); | ||
| const address = await delegateeSigner.getAddress(); | ||
|
|
||
| const siweMessage = new SiweMessage({ | ||
| address, | ||
| chainId: 1, | ||
| domain: VINCENT_REGISTRY_URL, | ||
| issuedAt: new Date().toISOString(), | ||
| nonce: generateNonce(), | ||
| statement: 'Sign in with Ethereum to authenticate with Vincent Registry API', | ||
| uri: VINCENT_REGISTRY_URL, | ||
| version: '1', | ||
| }); | ||
| const message = siweMessage.prepareMessage(); | ||
| const signature = await delegateeSigner.signMessage(message); | ||
| const authorization = `SIWE ${Buffer.from(JSON.stringify({ message, signature })).toString('base64')}`; | ||
|
|
||
| const registryAppResponse = await fetch(`${VINCENT_REGISTRY_URL}/app/${appId}`, { | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| headers: { | ||
| Authorization: authorization, | ||
| }, | ||
| }); | ||
| if (!registryAppResponse.ok) { | ||
| throw new Error( | ||
| `Failed to retrieve app data for ${appId}. Request status code: ${registryAppResponse.status}, error: ${await registryAppResponse.text()}, `, | ||
| ); | ||
| } | ||
| const registryData = (await registryAppResponse.json()) as RegistryApp; | ||
| if (registryData.isDeleted) { | ||
| throw new Error(`Vincent App ${appId} has been deleted from the registry`); | ||
| } | ||
| if (registryData.deploymentStatus !== 'prod') { | ||
| console.warn( | ||
| `Warning: Vincent App ${appId} is deployed as ${registryData.deploymentStatus}. Consider migrating to a production deployment.`, | ||
| ); | ||
| } | ||
|
|
||
| const appVersion = registryData.activeVersion.toString(); | ||
|
|
||
| const registryToolsResponse = await fetch( | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| `${VINCENT_REGISTRY_URL}/app/${appId}/version/${appVersion}/tools`, | ||
| { | ||
| headers: { | ||
| Authorization: authorization, | ||
| }, | ||
| }, | ||
| ); | ||
| const registryTools = (await registryToolsResponse.json()) as RegistryAppVersionTool[]; | ||
| if (!registryToolsResponse.ok) { | ||
| throw new Error( | ||
| `Failed to retrieve tools for ${appId}. Request status code: ${registryToolsResponse.status}, error: ${await registryToolsResponse.text()}`, | ||
| ); | ||
| } | ||
|
|
||
| let toolsObject: VincentAppTools = {}; | ||
| registryTools.forEach((rt) => { | ||
| if (rt.isDeleted) { | ||
| throw new Error( | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| `Vincent App Version Tool ${rt.toolPackageName}@${rt.toolVersion} has been deleted from the registry`, | ||
| ); | ||
| } | ||
| toolsObject[rt.toolPackageName] = { | ||
| version: rt.toolVersion, | ||
| }; | ||
| }); | ||
|
|
||
| await installToolPackages(toolsObject); | ||
| toolsObject = await registerVincentTools(toolsObject); | ||
|
|
||
| return { | ||
| id: appId, | ||
| version: appVersion, | ||
| name: registryData.name, | ||
| description: registryData?.description, | ||
| tools: toolsObject, | ||
| }; | ||
| } | ||
|
|
||
| function mergeToolData( | ||
FedericoAmura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| jsonTools: JsonVincentAppTools | undefined, | ||
| registryTools: VincentAppTools, | ||
| ): VincentAppTools { | ||
| if (!jsonTools) return registryTools; | ||
|
|
||
| const mergedTools: VincentAppTools = {}; | ||
| Object.entries(jsonTools).forEach(([toolKey, toolValue]) => { | ||
| mergedTools[toolKey] = Object.assign({}, registryTools[toolKey], toolValue); | ||
| }); | ||
|
|
||
| return mergedTools; | ||
| } | ||
|
|
||
| export async function getVincentAppDef(): Promise<VincentAppDef> { | ||
| // Load data from the App definition JSON | ||
| const appJson = VINCENT_APP_JSON_DEFINITION | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ? fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' }) | ||
| : '{}'; | ||
| const jsonData = JsonVincentAppSchema.parse(JSON.parse(appJson)) as Partial<VincentAppDef>; | ||
|
|
||
| if (!VINCENT_APP_ID && !jsonData.id) { | ||
FedericoAmura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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...`, | ||
| ); | ||
| } | ||
|
|
||
| const vincentAppId = jsonData.id ?? (VINCENT_APP_ID as string); | ||
| const registryData = await getAppDataFromRegistry(vincentAppId); | ||
|
|
||
| const vincentAppDef = VincentAppDefSchema.parse({ | ||
| id: vincentAppId, | ||
| name: jsonData.name || registryData.name, | ||
| version: jsonData.version || registryData.version, | ||
FedericoAmura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| description: jsonData.description || registryData.description, | ||
| tools: mergeToolData(jsonData.tools, registryData.tools), | ||
| }); | ||
|
|
||
| return vincentAppDef; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,15 +56,16 @@ export async function authenticateWithSiwe( | |
| signature: string, | ||
| ): Promise<string> { | ||
| 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() || | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a blocker since this is going to be replaced with JWT shortly -- but FYI for the next time you're writing some SIWE logic:
|
||
| uri !== EXPECTED_AUDIENCE | ||
| ) { | ||
| throw new Error('SIWE message verification failed'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,33 +14,32 @@ | |
| * @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 { 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 +116,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 +137,14 @@ app.get('/siwe', async (req: Request, res: Response) => { | |
|
|
||
| app.post('/mcp', async (req: Request, res: Response) => { | ||
| try { | ||
| if (!appDef) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a blocker -- but I'm pretty sure that this case can never actually happen, because we don't call FYI the way to avoid this kind of ambiguity is to wrap your route handler registration in a function(s) that you call from the async Then you can pass the async fetched data (appDef) as a strongly typed entity into your |
||
| return returnWithError( | ||
| res, | ||
| 500, | ||
| 'Vincent App Definition has not been loaded. Restart server', | ||
| ); | ||
| } | ||
|
|
||
| const sessionId = req.headers['mcp-session-id'] as string | undefined; | ||
| let transport: StreamableHTTPServerTransport; | ||
|
|
||
|
|
@@ -204,7 +211,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 returnWithError( | ||
|
|
@@ -219,7 +226,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, | ||
| }); | ||
|
|
@@ -271,6 +278,27 @@ app.delete('/mcp', async (req: Request, res: Response) => { | |
| return returnWithError(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(); | ||
|
|
||
| server.close(() => { | ||
| console.log('🛑 Vincent MCP Server has been closed.'); | ||
| process.exit(0); | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
| process.on('SIGINT', gracefulShutdown); | ||
FedericoAmura marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| process.on('SIGTERM', gracefulShutdown); | ||
| } | ||
| startServer().catch((error) => { | ||
| console.error('Fatal error starting Vincent MCP server in HTTP mode:', error); | ||
| process.exit(1); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.