Phase 4: Create standalone authorization server
- Created auth-server/index.ts with full OAuth endpoint support - Reuses EverythingAuthProvider from main server - Added custom /oauth/introspect endpoint for token validation - Includes fake upstream auth routes for user authentication - Added TypeScript configuration for auth server - Added comprehensive npm scripts for all modes: - dev:integrated, dev:separate, dev:auth-server - dev:with-separate-auth (runs both servers) - build:auth-server, build:all - Added concurrently dependency for running multiple servers - Added detailed README for auth server - Successfully serves OAuth metadata and health endpoints
This commit is contained in:
parent
4771c6eb34
commit
5c408eb43d
5 changed files with 381 additions and 2 deletions
89
auth-server/README.md
Normal file
89
auth-server/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# MCP Standalone Authorization Server
|
||||
|
||||
This is a demonstration OAuth 2.0 authorization server for MCP.
|
||||
|
||||
## Purpose
|
||||
|
||||
This server demonstrates how MCP servers can delegate authentication to a separate
|
||||
authorization server (Mode 2 in our implementation). In production environments,
|
||||
you would typically use established OAuth providers like:
|
||||
|
||||
- Auth0
|
||||
- Okta
|
||||
- Google OAuth
|
||||
- GitHub OAuth
|
||||
- Microsoft Azure AD
|
||||
|
||||
## Architecture
|
||||
|
||||
When running in separate mode, the architecture looks like:
|
||||
|
||||
1. MCP Client (e.g., Inspector) discovers auth server URL from MCP server metadata
|
||||
2. Client registers and authenticates directly with this auth server
|
||||
3. Auth server issues tokens
|
||||
4. MCP server validates tokens by calling this auth server's introspection endpoint
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `/.well-known/oauth-authorization-server` - OAuth 2.0 server metadata
|
||||
- `/oauth/authorize` - Authorization endpoint
|
||||
- `/oauth/token` - Token endpoint
|
||||
- `/oauth/register` - Dynamic client registration
|
||||
- `/oauth/introspect` - Token introspection (for MCP server validation)
|
||||
- `/fakeupstreamauth/authorize` - Fake upstream auth page (demo only)
|
||||
- `/fakeupstreamauth/callback` - Fake upstream callback (demo only)
|
||||
- `/health` - Health check endpoint
|
||||
|
||||
## Development
|
||||
|
||||
This server shares Redis with the MCP server for development convenience.
|
||||
In production, these would typically be separate.
|
||||
|
||||
## Running the Auth Server
|
||||
|
||||
### Standalone
|
||||
```bash
|
||||
# From the repository root
|
||||
npm run dev:auth-server
|
||||
```
|
||||
|
||||
### With MCP Server (Separate Mode)
|
||||
```bash
|
||||
# Start both servers together
|
||||
npm run dev:with-separate-auth
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
### OAuth Metadata
|
||||
```bash
|
||||
curl http://localhost:3001/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
### With MCP Inspector
|
||||
1. Start this auth server: `npm run dev:auth-server`
|
||||
2. Start MCP server in separate mode: `AUTH_MODE=separate npm run dev`
|
||||
3. Open Inspector: `npx -y @modelcontextprotocol/inspector`
|
||||
4. Connect to `http://localhost:3232`
|
||||
5. Auth flow will redirect to this server (port 3001)
|
||||
|
||||
## Configuration
|
||||
|
||||
The auth server uses the same environment variables as the main server:
|
||||
- `AUTH_SERVER_PORT` - Port to run on (default: 3001)
|
||||
- `AUTH_SERVER_URL` - Base URL (default: http://localhost:3001)
|
||||
- `REDIS_URL` - Redis connection (shared with MCP server)
|
||||
|
||||
## Production Considerations
|
||||
|
||||
In production:
|
||||
- Use real OAuth providers instead of this demonstration server
|
||||
- Separate Redis instances for auth and resource servers
|
||||
- Enable HTTPS with proper certificates
|
||||
- Implement proper rate limiting and monitoring
|
||||
- Use secure client secrets and token rotation
|
||||
146
auth-server/index.ts
Normal file
146
auth-server/index.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { EverythingAuthProvider } from '../src/auth/provider.js';
|
||||
import { handleFakeAuthorize, handleFakeAuthorizeRedirect } from '../src/handlers/fakeauth.js';
|
||||
import { redisClient } from '../src/redis.js';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
import { AUTH_SERVER_PORT, AUTH_SERVER_URL } from '../src/config.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
console.log('=====================================');
|
||||
console.log('MCP Demonstration Authorization Server');
|
||||
console.log('=====================================');
|
||||
console.log('This standalone server demonstrates OAuth 2.0');
|
||||
console.log('authorization separate from the MCP resource server');
|
||||
console.log('');
|
||||
console.log('This is for demonstration purposes only.');
|
||||
console.log('In production, you would use a real OAuth provider');
|
||||
console.log('like Auth0, Okta, Google, GitHub, etc.');
|
||||
console.log('=====================================');
|
||||
|
||||
// CORS for Inspector and MCP server
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(logger.middleware());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
mode: 'authorization-server',
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/oauth/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/oauth/token`,
|
||||
register: `${AUTH_SERVER_URL}/oauth/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/oauth/introspect`
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create auth provider instance for reuse
|
||||
const authProvider = new EverythingAuthProvider();
|
||||
|
||||
// OAuth endpoints via SDK's mcpAuthRouter
|
||||
app.use(mcpAuthRouter({
|
||||
provider: authProvider,
|
||||
issuerUrl: new URL(AUTH_SERVER_URL),
|
||||
tokenOptions: {
|
||||
rateLimit: { windowMs: 5000, limit: 100 }
|
||||
},
|
||||
clientRegistrationOptions: {
|
||||
rateLimit: { windowMs: 60000, limit: 10 }
|
||||
}
|
||||
}));
|
||||
|
||||
// Token introspection endpoint (RFC 7662)
|
||||
app.post('/oauth/introspect', express.urlencoded({ extended: false }), async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'Missing token parameter' });
|
||||
}
|
||||
|
||||
// Verify the token using the auth provider
|
||||
const authInfo = await authProvider.verifyAccessToken(token);
|
||||
|
||||
// Return RFC 7662 compliant response
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: authInfo.clientId,
|
||||
scope: authInfo.scopes.join(' '),
|
||||
exp: authInfo.expiresAt,
|
||||
sub: authInfo.extra?.userId || 'unknown',
|
||||
userId: authInfo.extra?.userId, // Custom field for our implementation
|
||||
username: authInfo.extra?.username,
|
||||
iss: AUTH_SERVER_URL,
|
||||
aud: authInfo.clientId,
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.debug('Token introspection failed', { error: (error as Error).message });
|
||||
|
||||
// Return inactive token response (don't leak error details)
|
||||
res.json({
|
||||
active: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fake upstream auth endpoints (for user authentication simulation)
|
||||
app.get('/fakeupstreamauth/authorize', cors(), handleFakeAuthorize);
|
||||
app.get('/fakeupstreamauth/callback', cors(), handleFakeAuthorizeRedirect);
|
||||
|
||||
// Static assets (for auth page styling)
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
app.get('/mcp-logo.png', (req, res) => {
|
||||
// Serve from the main server's static directory
|
||||
const logoPath = path.join(__dirname, '../src/static/mcp.png');
|
||||
res.sendFile(logoPath);
|
||||
});
|
||||
|
||||
// Connect to Redis (shared with MCP server in dev)
|
||||
try {
|
||||
await redisClient.connect();
|
||||
logger.info('Connected to Redis', { url: redisClient.options?.url });
|
||||
} catch (error) {
|
||||
logger.error('Could not connect to Redis', error as Error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(AUTH_SERVER_PORT, () => {
|
||||
logger.info('Authorization server started', {
|
||||
port: AUTH_SERVER_PORT,
|
||||
url: AUTH_SERVER_URL,
|
||||
endpoints: {
|
||||
metadata: `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
|
||||
authorize: `${AUTH_SERVER_URL}/oauth/authorize`,
|
||||
token: `${AUTH_SERVER_URL}/oauth/token`,
|
||||
register: `${AUTH_SERVER_URL}/oauth/register`,
|
||||
introspect: `${AUTH_SERVER_URL}/oauth/introspect`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('🚀 Auth server ready! Test with:');
|
||||
console.log(` curl ${AUTH_SERVER_URL}/health`);
|
||||
console.log(` curl ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`);
|
||||
console.log('');
|
||||
console.log('💡 To test separate mode:');
|
||||
console.log(' 1. Keep this server running');
|
||||
console.log(' 2. In another terminal: AUTH_MODE=separate npm run dev');
|
||||
console.log(' 3. Connect Inspector to http://localhost:3232');
|
||||
});
|
||||
9
auth-server/tsconfig.json
Normal file
9
auth-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/auth-server",
|
||||
"rootDir": "../"
|
||||
},
|
||||
"include": ["index.ts", "../src/**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["../dist", "../node_modules", "../**/*.test.ts"]
|
||||
}
|
||||
125
package-lock.json
generated
125
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
|
|
@ -484,6 +485,16 @@
|
|||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@babel/runtime/-/runtime-7.28.3.tgz",
|
||||
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
|
|
@ -3114,6 +3125,50 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/concurrently/-/concurrently-8.2.2.tgz",
|
||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
|
|
@ -3206,6 +3261,23 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
|
@ -5319,6 +5391,13 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
|
@ -6224,6 +6303,16 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
|
@ -6350,6 +6439,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
|
@ -6467,6 +6569,12 @@
|
|||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
|
|
@ -6651,6 +6759,16 @@
|
|||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
|
|
@ -6743,6 +6861,13 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.5",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -6,12 +6,21 @@
|
|||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"start:auth-server": "node dist/auth-server/index.js",
|
||||
"dev": "tsx watch --inspect src/index.ts",
|
||||
"dev:break": "tsx --inspect-brk watch src/index.ts",
|
||||
"dev:integrated": "AUTH_MODE=integrated npm run dev",
|
||||
"dev:separate": "AUTH_MODE=separate AUTH_SERVER_URL=http://localhost:3001 npm run dev",
|
||||
"dev:auth-server": "AUTH_SERVER_PORT=3001 tsx watch --inspect auth-server/index.ts",
|
||||
"dev:with-separate-auth": "concurrently -n \"AUTH,MCP\" -c \"yellow,cyan\" \"npm run dev:auth-server\" \"npm run dev:separate\"",
|
||||
"build": "tsc && npm run copy-static",
|
||||
"build:auth-server": "tsc -p auth-server/tsconfig.json",
|
||||
"build:all": "tsc && tsc -p auth-server/tsconfig.json && npm run copy-static",
|
||||
"copy-static": "mkdir -p dist/static && cp -r src/static/* dist/static/",
|
||||
"lint": "eslint src/",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
|
||||
"lint": "eslint src/ auth-server/",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"test:integrated": "AUTH_MODE=integrated npm test",
|
||||
"test:separate": "AUTH_MODE=separate npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
|
|
@ -20,6 +29,7 @@
|
|||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
|
|
|
|||
Loading…
Reference in a new issue