Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
41a20bc
feat: first implementation of mcp building based on npm pkgs
FedericoAmura Jul 2, 2025
9cda182
feat: first implementation of mcp building based on npm pkgs - mcp sd…
FedericoAmura Jul 2, 2025
b24254d
feat: servers graceful shutdown, json and registry definitions consol…
FedericoAmura Jul 3, 2025
4774cea
feat: deleted app or tool checks and not prod notification
FedericoAmura Jul 4, 2025
3e8d8cf
fix: remove unused pkgs in mcp-sdk
FedericoAmura Jul 4, 2025
10a1052
feat: use pnpm locally for tools pkg install but npm for out-in-the-w…
FedericoAmura Jul 4, 2025
4ccb43a
feat: comment on require vs import
FedericoAmura Jul 4, 2025
b6f7368
Merge branch 'refs/heads/main' into feature/drel-817-migrate-mcp-from…
FedericoAmura Jul 4, 2025
c1e3897
fix: remove old version plan
FedericoAmura Jul 4, 2025
8c16a12
feat: move tool pkgs installation to server start and away from first…
FedericoAmura Jul 4, 2025
a6d838a
fix: incorrect bundled vincent tool destructuring
FedericoAmura Jul 4, 2025
2480c2c
fix: move which types dev dependency
FedericoAmura Jul 4, 2025
72221c4
chore: code documentation
FedericoAmura Jul 4, 2025
5ce2a6d
chore: doc and readme updates
FedericoAmura Jul 4, 2025
1f620fd
chore: add release plan
FedericoAmura Jul 4, 2025
74da7c6
feat: add descriptions exposed by tools
FedericoAmura Jul 7, 2025
e168934
Merge branch 'main' into feature/drel-817-migrate-mcp-from-ipfc-cids-…
FedericoAmura Jul 9, 2025
b374d76
Merge branch 'main' into feature/drel-817-migrate-mcp-from-ipfc-cids-…
FedericoAmura Jul 9, 2025
2ec989c
fix: use once to call graceful shutdown function
FedericoAmura Jul 9, 2025
de34b8c
feat: convert parameters array in app def json into an object
FedericoAmura Jul 9, 2025
1b1ea11
Merge branch 'refs/heads/main' into feature/drel-817-migrate-mcp-from…
FedericoAmura Jul 10, 2025
6102180
feat: migrate from npm and pnpm to npx-import
FedericoAmura Jul 10, 2025
5e70421
fix: move tools sdk as prod dep in mcp server
FedericoAmura Jul 10, 2025
3a56bcb
Merge branch 'refs/heads/main' into feature/drel-817-migrate-mcp-from…
FedericoAmura Jul 10, 2025
bd08339
feat: use registry sdk and allow for version input as env variable
FedericoAmura Jul 10, 2025
5bcd28f
feat: require mcp-sdk receives vincent bundled tools in its app defin…
FedericoAmura Jul 11, 2025
577ecdb
feat: move tools sdk in mcp sdk to dev dep
FedericoAmura Jul 11, 2025
4e84739
chore: update docs
FedericoAmura Jul 11, 2025
a5680f6
chore: comments updates
FedericoAmura Jul 11, 2025
eccf6ed
fix: set exitCode instead of forcefully exiting
FedericoAmura Jul 11, 2025
5303ce0
feat: just warn and skip on registry deleted tools
FedericoAmura Jul 11, 2025
b298aeb
feat: app version enabled check
FedericoAmura Jul 11, 2025
1a6d82f
feat: app tool in json file exists in registry
FedericoAmura Jul 11, 2025
c49d29f
feat: harden tool failure check
FedericoAmura Jul 11, 2025
bc57d89
fix: make tool params describe override non-mutative on the vincent tool
FedericoAmura Jul 11, 2025
02d8822
fix: removed tools sdk from the wrong package json file
FedericoAmura Jul 11, 2025
99e2d6d
feat: separate env verifications for stdio and http execution making …
FedericoAmura Jul 13, 2025
a9040ab
feat: migrate nonce and transport managers to node-cache
FedericoAmura Jul 13, 2025
58d1109
Merge branch 'main' into feature/drel-817-migrate-mcp-from-ipfc-cids-…
FedericoAmura Jul 13, 2025
5033694
chore: update lockfile
FedericoAmura Jul 13, 2025
51f1340
feat: migrate to the registerTool syntax and add getters also as reso…
FedericoAmura Jul 14, 2025
70d6bac
feat: close transport and nonce managers on http server cleanup
FedericoAmura Jul 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .nx/version-plans/version-plan-1750681850131.md

This file was deleted.

3 changes: 3 additions & 0 deletions packages/apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"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",
"integration:openAI": "tsx --env-file=.env ./integrations/openAI.ts",
"integration:anthropic": "tsx --env-file=.env ./integrations/anthropic.ts"
Expand All @@ -44,12 +45,14 @@
"express": "^5.1.0",
"siwe": "^3.0.0",
"tslib": "^2.8.1",
"which": "^5.0.0",
"zod": "^3.25.64"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.52.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.1",
"@types/which": "^3.0.4",
"openai": "^5.0.1",
"tsx": "^4.19.4"
}
Expand Down
231 changes: 231 additions & 0 deletions packages/apps/mcp/src/appDefBuilder.ts
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) => {
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> {
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> {
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}`, {
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(
`${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(
`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(
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I assume that this always relative to the directory that the user ran the npx command in -- not the directory containing the .env file, is that right? 🤔
  2. We should be using path.resolve() to compose this path string so it'll work consistently across POSIX and win32

? fs.readFileSync(VINCENT_APP_JSON_DEFINITION, { encoding: 'utf8' })
: '{}';
const jsonData = JsonVincentAppSchema.parse(JSON.parse(appJson)) as Partial<VincentAppDef>;

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...`,
);
}

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,
description: jsonData.description || registryData.description,
tools: mergeToolData(jsonData.tools, registryData.tools),
});

return vincentAppDef;
}
9 changes: 5 additions & 4 deletions packages/apps/mcp/src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ||
Copy link
Contributor

Choose a reason for hiding this comment

The 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:

  1. SIWE.verify() already checks expirationTime in verify()
  2. There's a notBefore property in the SIWE message that is used for this purpose (and verify() checks that too) -- issuedAt is informative but not restrictive, but notBefore is explicitly restrictive.

uri !== EXPECTED_AUDIENCE
) {
throw new Error('SIWE message verification failed');
Expand Down
54 changes: 41 additions & 13 deletions packages/apps/mcp/src/bin/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand All @@ -138,6 +137,14 @@ app.get('/siwe', async (req: Request, res: Response) => {

app.post('/mcp', async (req: Request, res: Response) => {
try {
if (!appDef) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 listen() until after we've loaded the appDef -- so no requests will ever be received until the appDef has been set.

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 startServer() method instead of having them bind to the app when the module is parsed -- especially if you need some async data before you can sanely register them.

Then you can pass the async fetched data (appDef) as a strongly typed entity into your registerRoutes() method(s) and know that it is always present when the routes get added -- and you then call .listen() on the server after registering all of the routes. You can see what that looks like here in the registry-backend.

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;

Expand Down Expand Up @@ -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(
Expand All @@ -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,
});
Expand Down Expand Up @@ -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);
});
}
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
}
startServer().catch((error) => {
console.error('Fatal error starting Vincent MCP server in HTTP mode:', error);
process.exit(1);
});
Loading