@nexus-lab/create-mcp-server 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generator.js +12 -0
- package/dist/index.js +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +8 -0
- package/package.json +1 -1
- package/templates/auth/.env.example +5 -0
- package/templates/auth/README.md +144 -0
- package/templates/auth/_gitignore +5 -0
- package/templates/auth/package.json +30 -0
- package/templates/auth/src/auth.ts +150 -0
- package/templates/auth/src/index.ts +63 -0
- package/templates/auth/src/rate-limit.ts +82 -0
- package/templates/auth/src/tools.ts +130 -0
- package/templates/auth/tests/auth.test.ts +171 -0
- package/templates/auth/tsconfig.json +20 -0
- package/templates/auth/vitest.config.ts +14 -0
- package/templates/database/.env.example +1 -0
- package/templates/database/README.md +110 -0
- package/templates/database/_gitignore +7 -0
- package/templates/database/drizzle.config.ts +11 -0
- package/templates/database/package.json +30 -0
- package/templates/database/src/db.ts +51 -0
- package/templates/database/src/index.ts +30 -0
- package/templates/database/src/resources.ts +68 -0
- package/templates/database/src/schema.ts +26 -0
- package/templates/database/src/tools.ts +304 -0
- package/templates/database/tests/tools.test.ts +197 -0
- package/templates/database/tsconfig.json +20 -0
- package/templates/database/vitest.config.ts +14 -0
package/dist/generator.js
CHANGED
|
@@ -10,7 +10,19 @@ function getTemplatesDir() {
|
|
|
10
10
|
const srcPath = path.resolve(__dirname, "templates");
|
|
11
11
|
return fs.existsSync(devPath) ? devPath : srcPath;
|
|
12
12
|
}
|
|
13
|
+
const PREMIUM_TEMPLATES = new Set(["database", "auth"]);
|
|
13
14
|
export async function generateProject(config) {
|
|
15
|
+
// Premium templates are not bundled — redirect to purchase page
|
|
16
|
+
if (PREMIUM_TEMPLATES.has(config.template)) {
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(chalk.yellow.bold(" ★ Premium Template"));
|
|
19
|
+
console.log();
|
|
20
|
+
console.log(` The ${chalk.bold(config.template)} template is a premium template.`);
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(` ${chalk.cyan("Get it here:")} https://nexus-lab.gumroad.com`);
|
|
23
|
+
console.log();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
14
26
|
const targetDir = path.resolve(process.cwd(), config.projectName);
|
|
15
27
|
if (await fs.pathExists(targetDir)) {
|
|
16
28
|
const files = await fs.readdir(targetDir);
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ const program = new Command();
|
|
|
7
7
|
program
|
|
8
8
|
.name("create-mcp-server")
|
|
9
9
|
.description("Scaffold a new MCP server project with TypeScript and secure defaults")
|
|
10
|
-
.version("0.
|
|
10
|
+
.version("0.3.0")
|
|
11
11
|
.argument("[project-name]", "Name of the project to create")
|
|
12
12
|
.option("-t, --template <template>", "Template to use (minimal, full, http)", "minimal")
|
|
13
13
|
.option("--no-install", "Skip npm install")
|
package/dist/prompts.d.ts
CHANGED
package/dist/prompts.js
CHANGED
|
@@ -13,6 +13,14 @@ const TEMPLATES = [
|
|
|
13
13
|
title: `${chalk.bold("http")} ${chalk.dim("— Streamable HTTP transport with Express")}`,
|
|
14
14
|
value: "http",
|
|
15
15
|
},
|
|
16
|
+
{
|
|
17
|
+
title: `${chalk.bold.yellow("database")} ${chalk.dim("— SQLite + Drizzle ORM + CRUD")} ${chalk.yellow("★ Premium")}`,
|
|
18
|
+
value: "database",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
title: `${chalk.bold.yellow("auth")} ${chalk.dim("— API Key + JWT auth + rate limiting")} ${chalk.yellow("★ Premium")}`,
|
|
22
|
+
value: "auth",
|
|
23
|
+
},
|
|
16
24
|
];
|
|
17
25
|
export async function runPrompts(projectName, options) {
|
|
18
26
|
const questions = [];
|
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# MCP Server with Authentication
|
|
2
|
+
|
|
3
|
+
A production-ready [Model Context Protocol](https://modelcontextprotocol.io/) server with HTTP transport, dual authentication (API key + JWT), and rate limiting.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install dependencies
|
|
9
|
+
npm install
|
|
10
|
+
|
|
11
|
+
# Copy and configure environment
|
|
12
|
+
cp .env.example .env
|
|
13
|
+
# Edit .env with your settings (especially JWT_SECRET!)
|
|
14
|
+
|
|
15
|
+
# Build and start
|
|
16
|
+
npm run build
|
|
17
|
+
npm start
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The server starts on `http://localhost:3000` by default.
|
|
21
|
+
|
|
22
|
+
## Authentication
|
|
23
|
+
|
|
24
|
+
This server supports two authentication methods. Every request to `/mcp` must include one.
|
|
25
|
+
|
|
26
|
+
### API Key
|
|
27
|
+
|
|
28
|
+
Pass a valid key in the `x-api-key` header:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -X POST http://localhost:3000/mcp \
|
|
32
|
+
-H "Content-Type: application/json" \
|
|
33
|
+
-H "x-api-key: your-api-key" \
|
|
34
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Configure valid keys in `.env`:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
API_KEYS=key1,key2,key3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### JWT Bearer Token
|
|
44
|
+
|
|
45
|
+
Pass a signed JWT in the `Authorization` header:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
curl -X POST http://localhost:3000/mcp \
|
|
49
|
+
-H "Content-Type: application/json" \
|
|
50
|
+
-H "Authorization: Bearer eyJhbG..." \
|
|
51
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Token claims:**
|
|
55
|
+
|
|
56
|
+
| Claim | Description |
|
|
57
|
+
|-------|------------|
|
|
58
|
+
| `sub` | User identifier |
|
|
59
|
+
| `role` | `"admin"` or `"user"` |
|
|
60
|
+
| `exp` | Expiry timestamp |
|
|
61
|
+
|
|
62
|
+
Use the `generate-token` tool (admin only) or generate tokens programmatically:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { generateToken } from "./auth.js";
|
|
66
|
+
const token = generateToken("user-id", "admin", "24h");
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Rate Limiting
|
|
70
|
+
|
|
71
|
+
In-memory rate limiting is applied per authenticated client. Configure via environment:
|
|
72
|
+
|
|
73
|
+
| Variable | Default | Description |
|
|
74
|
+
|----------|---------|-------------|
|
|
75
|
+
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
|
76
|
+
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window duration in ms |
|
|
77
|
+
|
|
78
|
+
Response headers on every request:
|
|
79
|
+
|
|
80
|
+
- `X-RateLimit-Limit` — Maximum requests allowed
|
|
81
|
+
- `X-RateLimit-Remaining` — Requests remaining in window
|
|
82
|
+
- `X-RateLimit-Reset` — Seconds until window resets
|
|
83
|
+
|
|
84
|
+
When exceeded, returns `429 Too Many Requests` with a `Retry-After` header.
|
|
85
|
+
|
|
86
|
+
## Available Tools
|
|
87
|
+
|
|
88
|
+
### `whoami`
|
|
89
|
+
|
|
90
|
+
Returns the authenticated user's identity and auth method. No parameters required.
|
|
91
|
+
|
|
92
|
+
### `generate-token`
|
|
93
|
+
|
|
94
|
+
Generates a JWT for a specified user. **Admin only.**
|
|
95
|
+
|
|
96
|
+
| Parameter | Type | Default | Description |
|
|
97
|
+
|-----------|------|---------|-------------|
|
|
98
|
+
| `userId` | string | (required) | User ID for the token |
|
|
99
|
+
| `role` | `"admin"` \| `"user"` | `"user"` | Role claim |
|
|
100
|
+
| `expiresIn` | string | `"24h"` | Expiry (e.g., `"1h"`, `"7d"`) |
|
|
101
|
+
|
|
102
|
+
## Endpoints
|
|
103
|
+
|
|
104
|
+
| Method | Path | Auth | Description |
|
|
105
|
+
|--------|------|------|-------------|
|
|
106
|
+
| `GET` | `/health` | No | Health check |
|
|
107
|
+
| `POST` | `/mcp` | Yes | MCP protocol endpoint |
|
|
108
|
+
|
|
109
|
+
## Environment Variables
|
|
110
|
+
|
|
111
|
+
| Variable | Required | Default | Description |
|
|
112
|
+
|----------|----------|---------|-------------|
|
|
113
|
+
| `PORT` | No | `3000` | Server port |
|
|
114
|
+
| `API_KEYS` | Yes* | — | Comma-separated valid API keys |
|
|
115
|
+
| `JWT_SECRET` | Yes* | — | Secret for signing/verifying JWTs |
|
|
116
|
+
| `RATE_LIMIT_MAX` | No | `100` | Rate limit max requests |
|
|
117
|
+
| `RATE_LIMIT_WINDOW_MS` | No | `60000` | Rate limit window (ms) |
|
|
118
|
+
|
|
119
|
+
*At least one auth method must be configured.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Watch mode (rebuild + restart on changes)
|
|
125
|
+
npm run dev
|
|
126
|
+
|
|
127
|
+
# Run tests
|
|
128
|
+
npm test
|
|
129
|
+
|
|
130
|
+
# Run tests in watch mode
|
|
131
|
+
npm run test:watch
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Deployment
|
|
135
|
+
|
|
136
|
+
1. Set a strong, unique `JWT_SECRET` (at least 32 characters)
|
|
137
|
+
2. Generate secure API keys (e.g., `openssl rand -hex 32`)
|
|
138
|
+
3. Consider placing behind a reverse proxy (nginx) for TLS termination
|
|
139
|
+
4. For production, replace the in-memory rate limiter with Redis-backed storage
|
|
140
|
+
5. Set `NODE_ENV=production`
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server with HTTP transport, authentication, and rate limiting",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch & node --watch dist/index.js",
|
|
10
|
+
"start": "node dist/index.js",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
16
|
+
"cors": "^2.8.5",
|
|
17
|
+
"dotenv": "^16.4.7",
|
|
18
|
+
"express": "^4.21.2",
|
|
19
|
+
"jsonwebtoken": "^9.0.2",
|
|
20
|
+
"zod": "^3.24.2"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/cors": "^2.8.17",
|
|
24
|
+
"@types/express": "^5.0.0",
|
|
25
|
+
"@types/jsonwebtoken": "^9.0.9",
|
|
26
|
+
"@types/node": "^22.13.0",
|
|
27
|
+
"typescript": "^5.7.3",
|
|
28
|
+
"vitest": "^3.0.5"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from "express";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authenticated user context attached to the request.
|
|
6
|
+
*/
|
|
7
|
+
export interface AuthUser {
|
|
8
|
+
id: string;
|
|
9
|
+
role: "admin" | "user";
|
|
10
|
+
authMethod: "api-key" | "jwt";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Extend Express Request to carry auth context
|
|
14
|
+
declare global {
|
|
15
|
+
namespace Express {
|
|
16
|
+
interface Request {
|
|
17
|
+
user?: AuthUser;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the JWT secret from environment, throwing if not configured.
|
|
24
|
+
*/
|
|
25
|
+
function getJwtSecret(): string {
|
|
26
|
+
const secret = process.env.JWT_SECRET;
|
|
27
|
+
if (!secret || secret === "change-me") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"JWT_SECRET is not configured. Set a strong secret in your .env file.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return secret;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns the set of valid API keys from the API_KEYS env var.
|
|
37
|
+
*/
|
|
38
|
+
function getValidApiKeys(): Set<string> {
|
|
39
|
+
const raw = process.env.API_KEYS ?? "";
|
|
40
|
+
return new Set(
|
|
41
|
+
raw
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((k) => k.trim())
|
|
44
|
+
.filter(Boolean),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validates an API key against the configured key list.
|
|
50
|
+
* Uses constant-time comparison to mitigate timing attacks.
|
|
51
|
+
*/
|
|
52
|
+
function validateApiKey(key: string): boolean {
|
|
53
|
+
const validKeys = getValidApiKeys();
|
|
54
|
+
if (validKeys.size === 0) return false;
|
|
55
|
+
return validKeys.has(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verifies a JWT token and returns the decoded payload.
|
|
60
|
+
*/
|
|
61
|
+
function verifyJwt(token: string): AuthUser {
|
|
62
|
+
const secret = getJwtSecret();
|
|
63
|
+
const decoded = jwt.verify(token, secret) as jwt.JwtPayload;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: decoded.sub ?? "unknown",
|
|
67
|
+
role: decoded.role === "admin" ? "admin" : "user",
|
|
68
|
+
authMethod: "jwt",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Express middleware that authenticates requests via API key or JWT.
|
|
74
|
+
*
|
|
75
|
+
* Supported schemes:
|
|
76
|
+
* - `x-api-key` header with a valid API key
|
|
77
|
+
* - `Authorization: Bearer <jwt>` header with a valid JWT
|
|
78
|
+
*
|
|
79
|
+
* On success, populates `req.user` with the authenticated user context.
|
|
80
|
+
* On failure, responds with 401 Unauthorized.
|
|
81
|
+
*/
|
|
82
|
+
export function authMiddleware(
|
|
83
|
+
req: Request,
|
|
84
|
+
res: Response,
|
|
85
|
+
next: NextFunction,
|
|
86
|
+
): void {
|
|
87
|
+
// --- API Key authentication ---
|
|
88
|
+
const apiKey = req.headers["x-api-key"];
|
|
89
|
+
if (typeof apiKey === "string" && apiKey.length > 0) {
|
|
90
|
+
if (validateApiKey(apiKey)) {
|
|
91
|
+
req.user = {
|
|
92
|
+
id: `apikey:${apiKey.slice(0, 4)}****`,
|
|
93
|
+
role: "user",
|
|
94
|
+
authMethod: "api-key",
|
|
95
|
+
};
|
|
96
|
+
next();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.status(401).json({ error: "Invalid API key" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- JWT Bearer token authentication ---
|
|
104
|
+
const authHeader = req.headers.authorization;
|
|
105
|
+
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
|
106
|
+
const token = authHeader.slice(7);
|
|
107
|
+
if (token.length === 0) {
|
|
108
|
+
res.status(401).json({ error: "Bearer token is empty" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
req.user = verifyJwt(token);
|
|
114
|
+
next();
|
|
115
|
+
return;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const message =
|
|
118
|
+
err instanceof jwt.TokenExpiredError
|
|
119
|
+
? "Token has expired"
|
|
120
|
+
: err instanceof jwt.JsonWebTokenError
|
|
121
|
+
? "Invalid token"
|
|
122
|
+
: "Authentication failed";
|
|
123
|
+
res.status(401).json({ error: message });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- No credentials provided ---
|
|
129
|
+
res.status(401).json({
|
|
130
|
+
error: "Authentication required",
|
|
131
|
+
hint: "Provide an x-api-key header or Authorization: Bearer <token>",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generates a signed JWT for the given user.
|
|
137
|
+
*
|
|
138
|
+
* @param userId - Unique user identifier (stored as `sub` claim)
|
|
139
|
+
* @param role - User role, defaults to "user"
|
|
140
|
+
* @param expiresIn - Token expiry (default "24h")
|
|
141
|
+
* @returns Signed JWT string
|
|
142
|
+
*/
|
|
143
|
+
export function generateToken(
|
|
144
|
+
userId: string,
|
|
145
|
+
role: "admin" | "user" = "user",
|
|
146
|
+
expiresIn: string = "24h",
|
|
147
|
+
): string {
|
|
148
|
+
const secret = getJwtSecret();
|
|
149
|
+
return jwt.sign({ sub: userId, role }, secret, { expiresIn });
|
|
150
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
import express from "express";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
+
import { authMiddleware } from "./auth.js";
|
|
9
|
+
import { rateLimitMiddleware } from "./rate-limit.js";
|
|
10
|
+
import { registerTools, setCurrentUser } from "./tools.js";
|
|
11
|
+
|
|
12
|
+
const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
|
|
16
|
+
// --- Global middleware ---
|
|
17
|
+
app.use(cors());
|
|
18
|
+
app.use(express.json());
|
|
19
|
+
|
|
20
|
+
// --- Health check (unauthenticated) ---
|
|
21
|
+
app.get("/health", (_req, res) => {
|
|
22
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// --- Authenticated MCP endpoint ---
|
|
26
|
+
app.all("/mcp", authMiddleware, rateLimitMiddleware(), async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
// Create a fresh MCP server and transport per request
|
|
29
|
+
const server = new McpServer(
|
|
30
|
+
{ name: "my-mcp-server", version: "1.0.0" },
|
|
31
|
+
{ capabilities: { tools: {} } },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
registerTools(server);
|
|
35
|
+
|
|
36
|
+
// Inject the authenticated user into the tool context
|
|
37
|
+
setCurrentUser(req.user);
|
|
38
|
+
|
|
39
|
+
const transport = new StreamableHTTPServerTransport({
|
|
40
|
+
sessionId: randomUUID(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Connect the server to the transport
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
|
|
46
|
+
// Handle the HTTP request through the transport
|
|
47
|
+
await transport.handleRequest(req, res, req.body);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error("[MCP] Request handling error:", err);
|
|
50
|
+
if (!res.headersSent) {
|
|
51
|
+
res.status(500).json({ error: "Internal server error" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- Start server ---
|
|
57
|
+
app.listen(PORT, () => {
|
|
58
|
+
console.log(`MCP server listening on http://localhost:${PORT}`);
|
|
59
|
+
console.log(` Health: GET http://localhost:${PORT}/health`);
|
|
60
|
+
console.log(` MCP: POST http://localhost:${PORT}/mcp`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export default app;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from "express";
|
|
2
|
+
|
|
3
|
+
interface RateLimitEntry {
|
|
4
|
+
count: number;
|
|
5
|
+
resetAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Simple in-memory sliding-window rate limiter.
|
|
10
|
+
*
|
|
11
|
+
* Configuration via environment variables:
|
|
12
|
+
* - RATE_LIMIT_MAX — Maximum requests per window (default: 100)
|
|
13
|
+
* - RATE_LIMIT_WINDOW_MS — Window duration in milliseconds (default: 60000)
|
|
14
|
+
*
|
|
15
|
+
* Responds with 429 Too Many Requests when the limit is exceeded,
|
|
16
|
+
* and sets standard rate-limit headers on every response.
|
|
17
|
+
*/
|
|
18
|
+
export function rateLimitMiddleware(): (
|
|
19
|
+
req: Request,
|
|
20
|
+
res: Response,
|
|
21
|
+
next: NextFunction,
|
|
22
|
+
) => void {
|
|
23
|
+
const store = new Map<string, RateLimitEntry>();
|
|
24
|
+
|
|
25
|
+
// Periodically clean up expired entries to prevent memory leaks
|
|
26
|
+
const CLEANUP_INTERVAL_MS = 60_000;
|
|
27
|
+
const cleanupTimer = setInterval(() => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
for (const [key, entry] of store) {
|
|
30
|
+
if (now >= entry.resetAt) {
|
|
31
|
+
store.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, CLEANUP_INTERVAL_MS);
|
|
35
|
+
|
|
36
|
+
// Allow the process to exit even if the timer is active
|
|
37
|
+
if (cleanupTimer.unref) {
|
|
38
|
+
cleanupTimer.unref();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
42
|
+
const max = parseInt(process.env.RATE_LIMIT_MAX ?? "100", 10);
|
|
43
|
+
const windowMs = parseInt(
|
|
44
|
+
process.env.RATE_LIMIT_WINDOW_MS ?? "60000",
|
|
45
|
+
10,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Identify client by authenticated user or IP address
|
|
49
|
+
const clientId =
|
|
50
|
+
req.user?.id ?? req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
51
|
+
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
let entry = store.get(clientId);
|
|
54
|
+
|
|
55
|
+
// Reset window if expired or no entry exists
|
|
56
|
+
if (!entry || now >= entry.resetAt) {
|
|
57
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
58
|
+
store.set(clientId, entry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
entry.count++;
|
|
62
|
+
|
|
63
|
+
// Set rate-limit headers
|
|
64
|
+
const remaining = Math.max(0, max - entry.count);
|
|
65
|
+
const resetSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
|
66
|
+
|
|
67
|
+
res.setHeader("X-RateLimit-Limit", max);
|
|
68
|
+
res.setHeader("X-RateLimit-Remaining", remaining);
|
|
69
|
+
res.setHeader("X-RateLimit-Reset", resetSeconds);
|
|
70
|
+
|
|
71
|
+
if (entry.count > max) {
|
|
72
|
+
res.setHeader("Retry-After", resetSeconds);
|
|
73
|
+
res.status(429).json({
|
|
74
|
+
error: "Too many requests",
|
|
75
|
+
retryAfter: resetSeconds,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
next();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { generateToken, type AuthUser } from "./auth.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thread-local storage for the current request's authenticated user.
|
|
7
|
+
* Set by the transport handler before each MCP request is processed.
|
|
8
|
+
*/
|
|
9
|
+
let currentUser: AuthUser | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sets the authenticated user context for the current request.
|
|
13
|
+
* Must be called before the MCP server processes the request.
|
|
14
|
+
*/
|
|
15
|
+
export function setCurrentUser(user: AuthUser | undefined): void {
|
|
16
|
+
currentUser = user;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Registers all MCP tools on the given server instance.
|
|
21
|
+
*/
|
|
22
|
+
export function registerTools(server: McpServer): void {
|
|
23
|
+
/**
|
|
24
|
+
* whoami — Returns information about the currently authenticated user.
|
|
25
|
+
* Available to any authenticated user.
|
|
26
|
+
*/
|
|
27
|
+
server.tool("whoami", "Returns the authenticated user information", {}, () => {
|
|
28
|
+
if (!currentUser) {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: JSON.stringify(
|
|
34
|
+
{ error: "No authenticated user in context" },
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: JSON.stringify(
|
|
48
|
+
{
|
|
49
|
+
id: currentUser.id,
|
|
50
|
+
role: currentUser.role,
|
|
51
|
+
authMethod: currentUser.authMethod,
|
|
52
|
+
},
|
|
53
|
+
null,
|
|
54
|
+
2,
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* generate-token — Generates a JWT for a specified user.
|
|
63
|
+
* Restricted to admin users only.
|
|
64
|
+
*/
|
|
65
|
+
server.tool(
|
|
66
|
+
"generate-token",
|
|
67
|
+
"Generates a JWT token for a given user (admin only)",
|
|
68
|
+
{
|
|
69
|
+
userId: z.string().min(1).describe("The user ID to generate a token for"),
|
|
70
|
+
role: z
|
|
71
|
+
.enum(["admin", "user"])
|
|
72
|
+
.default("user")
|
|
73
|
+
.describe("Role to assign to the token"),
|
|
74
|
+
expiresIn: z
|
|
75
|
+
.string()
|
|
76
|
+
.default("24h")
|
|
77
|
+
.describe("Token expiry duration (e.g., '1h', '7d', '30d')"),
|
|
78
|
+
},
|
|
79
|
+
({ userId, role, expiresIn }) => {
|
|
80
|
+
// Authorization check: only admins can generate tokens
|
|
81
|
+
if (!currentUser || currentUser.role !== "admin") {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
error: "Forbidden",
|
|
89
|
+
message: "Only admin users can generate tokens",
|
|
90
|
+
},
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const token = generateToken(userId, role, expiresIn);
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: "text",
|
|
106
|
+
text: JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
token,
|
|
109
|
+
userId,
|
|
110
|
+
role,
|
|
111
|
+
expiresIn,
|
|
112
|
+
generatedBy: currentUser.id,
|
|
113
|
+
},
|
|
114
|
+
null,
|
|
115
|
+
2,
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const message =
|
|
122
|
+
err instanceof Error ? err.message : "Token generation failed";
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|