Initial commit
This commit is contained in:
213
templates/authenticated-server.ts
Normal file
213
templates/authenticated-server.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Authenticated MCP Server Template
|
||||
*
|
||||
* An MCP server with API key authentication using Cloudflare KV.
|
||||
* Essential for production deployments to prevent unauthorized access.
|
||||
*
|
||||
* Setup:
|
||||
* 1. npm install @modelcontextprotocol/sdk hono zod
|
||||
* 2. npm install -D @cloudflare/workers-types wrangler typescript
|
||||
* 3. Create KV namespace: wrangler kv namespace create MCP_API_KEYS
|
||||
* 4. Add binding to wrangler.jsonc
|
||||
* 5. Add API keys: wrangler kv key put --binding=MCP_API_KEYS "key:YOUR_KEY" "true"
|
||||
* 6. wrangler deploy
|
||||
*
|
||||
* Usage:
|
||||
* - Clients must send Authorization header: "Bearer YOUR_API_KEY"
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Env = {
|
||||
MCP_API_KEYS: KVNamespace; // Required for authentication
|
||||
DB?: D1Database;
|
||||
CACHE?: KVNamespace;
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'authenticated-mcp-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Register tools
|
||||
server.registerTool(
|
||||
'secure-operation',
|
||||
{
|
||||
description: 'Performs a secure operation (requires authentication)',
|
||||
inputSchema: z.object({
|
||||
operation: z.string().describe('Operation to perform'),
|
||||
data: z.string().describe('Operation data')
|
||||
})
|
||||
},
|
||||
async ({ operation, data }) => {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Performed secure operation "${operation}" with data: ${data}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get-status',
|
||||
{
|
||||
description: 'Returns server status',
|
||||
inputSchema: z.object({})
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
status: 'running',
|
||||
authenticated: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// HTTP setup
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// CORS configuration (adjust origins for your use case)
|
||||
app.use('/mcp', cors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8787',
|
||||
'https://your-app.com' // Replace with your domain
|
||||
],
|
||||
allowMethods: ['POST', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Authentication middleware
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
// Check for Authorization header
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Missing or invalid Authorization header. Use: Authorization: Bearer YOUR_API_KEY'
|
||||
}, 401);
|
||||
}
|
||||
|
||||
// Extract API key
|
||||
const apiKey = authHeader.replace('Bearer ', '');
|
||||
|
||||
// Validate API key against KV store
|
||||
try {
|
||||
const storedKey = await c.env.MCP_API_KEYS.get(`key:${apiKey}`);
|
||||
|
||||
if (!storedKey) {
|
||||
return c.json({
|
||||
error: 'Forbidden',
|
||||
message: 'Invalid API key'
|
||||
}, 403);
|
||||
}
|
||||
|
||||
// Optional: Track API key usage
|
||||
const usageKey = `usage:${apiKey}:${new Date().toISOString().split('T')[0]}`;
|
||||
const currentUsage = await c.env.MCP_API_KEYS.get(usageKey);
|
||||
await c.env.MCP_API_KEYS.put(
|
||||
usageKey,
|
||||
String(parseInt(currentUsage || '0') + 1),
|
||||
{ expirationTtl: 86400 * 7 } // Keep for 7 days
|
||||
);
|
||||
|
||||
// User is authenticated, continue
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Authentication failed'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Rate limiting middleware (per IP)
|
||||
app.use('/mcp', async (c, next) => {
|
||||
const ip = c.req.header('CF-Connecting-IP') || 'unknown';
|
||||
const rateLimitKey = `ratelimit:${ip}:${Math.floor(Date.now() / 60000)}`; // Per minute
|
||||
|
||||
try {
|
||||
const count = await c.env.MCP_API_KEYS.get(rateLimitKey);
|
||||
const requestCount = parseInt(count || '0');
|
||||
|
||||
// Allow 100 requests per minute per IP
|
||||
if (requestCount >= 100) {
|
||||
return c.json({
|
||||
error: 'Rate Limit Exceeded',
|
||||
message: 'Too many requests. Please try again later.'
|
||||
}, 429);
|
||||
}
|
||||
|
||||
await c.env.MCP_API_KEYS.put(
|
||||
rateLimitKey,
|
||||
String(requestCount + 1),
|
||||
{ expirationTtl: 60 }
|
||||
);
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
// Rate limiting is best-effort, continue if it fails
|
||||
await next();
|
||||
}
|
||||
});
|
||||
|
||||
// Public health check (no auth required)
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
name: 'authenticated-mcp-server',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
authentication: 'required',
|
||||
mcp_endpoint: '/mcp',
|
||||
usage: 'Send POST requests to /mcp with Authorization: Bearer YOUR_API_KEY'
|
||||
});
|
||||
});
|
||||
|
||||
// Authenticated MCP endpoint
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* API Key Management Commands
|
||||
* ============================
|
||||
*
|
||||
* Add a new API key:
|
||||
* wrangler kv key put --binding=MCP_API_KEYS "key:abc123xyz" "true"
|
||||
*
|
||||
* Revoke an API key:
|
||||
* wrangler kv key delete --binding=MCP_API_KEYS "key:abc123xyz"
|
||||
*
|
||||
* List all API keys:
|
||||
* wrangler kv key list --binding=MCP_API_KEYS --prefix="key:"
|
||||
*
|
||||
* Check API key usage:
|
||||
* wrangler kv key get --binding=MCP_API_KEYS "usage:abc123xyz:2025-10-28"
|
||||
*
|
||||
* Generate secure API key (local):
|
||||
* node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
*/
|
||||
102
templates/basic-mcp-server.ts
Normal file
102
templates/basic-mcp-server.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Basic MCP Server Template
|
||||
*
|
||||
* A minimal Model Context Protocol server with a simple echo tool.
|
||||
* Deploy to Cloudflare Workers for serverless MCP endpoint.
|
||||
*
|
||||
* Setup:
|
||||
* 1. npm install @modelcontextprotocol/sdk hono zod
|
||||
* 2. npm install -D @cloudflare/workers-types wrangler typescript
|
||||
* 3. wrangler deploy
|
||||
*
|
||||
* Test locally:
|
||||
* - wrangler dev
|
||||
* - npx @modelcontextprotocol/inspector (connect to http://localhost:8787/mcp)
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define environment type (add your bindings here)
|
||||
type Env = {
|
||||
// Example: D1 database
|
||||
// DB: D1Database;
|
||||
// KV namespace
|
||||
// CACHE: KVNamespace;
|
||||
};
|
||||
|
||||
// Initialize MCP server
|
||||
const server = new McpServer({
|
||||
name: 'basic-mcp-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Register a simple echo tool
|
||||
server.registerTool(
|
||||
'echo',
|
||||
{
|
||||
description: 'Echoes back the input text',
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('Text to echo back')
|
||||
})
|
||||
},
|
||||
async ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
})
|
||||
);
|
||||
|
||||
// Register an addition tool
|
||||
server.registerTool(
|
||||
'add',
|
||||
{
|
||||
description: 'Adds two numbers together',
|
||||
inputSchema: z.object({
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().describe('Second number')
|
||||
})
|
||||
},
|
||||
async ({ a, b }) => ({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `The sum of ${a} and ${b} is ${a + b}`
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// HTTP endpoint setup with Hono
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
name: 'basic-mcp-server',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
mcp_endpoint: '/mcp'
|
||||
});
|
||||
});
|
||||
|
||||
// MCP endpoint
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
// CRITICAL: Close transport on response end to prevent memory leaks
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
|
||||
// CRITICAL: Use direct export (not object wrapper)
|
||||
// ✅ CORRECT:
|
||||
export default app;
|
||||
|
||||
// ❌ WRONG (causes build errors):
|
||||
// export default { fetch: app.fetch };
|
||||
318
templates/full-server.ts
Normal file
318
templates/full-server.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Full MCP Server Template
|
||||
*
|
||||
* A complete MCP server with tools, resources, AND prompts.
|
||||
* Demonstrates all MCP protocol capabilities.
|
||||
*
|
||||
* Setup:
|
||||
* 1. npm install @modelcontextprotocol/sdk hono zod
|
||||
* 2. npm install -D @cloudflare/workers-types wrangler typescript
|
||||
* 3. Configure bindings in wrangler.jsonc
|
||||
* 4. wrangler deploy
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Env = {
|
||||
DB?: D1Database;
|
||||
CACHE?: KVNamespace;
|
||||
API_KEY?: string;
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'full-mcp-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TOOLS
|
||||
// ============================================================================
|
||||
|
||||
server.registerTool(
|
||||
'search-database',
|
||||
{
|
||||
description: 'Searches the database for records matching a query',
|
||||
inputSchema: z.object({
|
||||
table: z.string().describe('Table name to search'),
|
||||
query: z.string().describe('Search query'),
|
||||
limit: z.number().default(10).describe('Maximum results to return')
|
||||
})
|
||||
},
|
||||
async ({ table, query, limit }, env) => {
|
||||
if (!env.DB) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Database not configured' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Simple example - in production, use proper SQL with parameterized queries
|
||||
const result = await env.DB
|
||||
.prepare(`SELECT * FROM ${table} WHERE name LIKE ? LIMIT ?`)
|
||||
.bind(`%${query}%`, limit)
|
||||
.all();
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Found ${result.results.length} results:\n${JSON.stringify(result.results, null, 2)}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Search failed: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'cache-set',
|
||||
{
|
||||
description: 'Sets a value in the cache',
|
||||
inputSchema: z.object({
|
||||
key: z.string().describe('Cache key'),
|
||||
value: z.string().describe('Value to cache'),
|
||||
expirationTtl: z.number().optional().describe('TTL in seconds')
|
||||
})
|
||||
},
|
||||
async ({ key, value, expirationTtl }, env) => {
|
||||
if (!env.CACHE) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Cache not configured' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await env.CACHE.put(key, value, expirationTtl ? { expirationTtl } : undefined);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Cached "${key}" successfully` }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Cache set failed: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'summarize-text',
|
||||
{
|
||||
description: 'Generates a summary of provided text',
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('Text to summarize'),
|
||||
maxLength: z.number().default(100).describe('Maximum summary length in words')
|
||||
})
|
||||
},
|
||||
async ({ text, maxLength }) => {
|
||||
const words = text.split(/\s+/);
|
||||
const summary = words.slice(0, maxLength).join(' ');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: summary + (words.length > maxLength ? '...' : '')
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// RESOURCES
|
||||
// ============================================================================
|
||||
|
||||
server.registerResource(
|
||||
'config',
|
||||
new ResourceTemplate('config://app', { list: undefined }),
|
||||
{
|
||||
title: 'Application Configuration',
|
||||
description: 'Server configuration and metadata'
|
||||
},
|
||||
async (uri) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
name: 'full-mcp-server',
|
||||
version: '1.0.0',
|
||||
capabilities: ['tools', 'resources', 'prompts'],
|
||||
features: {
|
||||
database: 'D1',
|
||||
cache: 'KV',
|
||||
tools: ['search-database', 'cache-set', 'summarize-text'],
|
||||
resources: ['config', 'stats', 'data'],
|
||||
prompts: ['greeting', 'analyze-data']
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'stats',
|
||||
new ResourceTemplate('stats://server', { list: undefined }),
|
||||
{
|
||||
title: 'Server Statistics',
|
||||
description: 'Current server statistics'
|
||||
},
|
||||
async (uri) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
uptime: process.uptime ? process.uptime() : 'N/A',
|
||||
timestamp: new Date().toISOString(),
|
||||
requestCount: 'N/A' // In production, track with Durable Objects
|
||||
}, null, 2)
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'data',
|
||||
new ResourceTemplate('data://{key}', { list: undefined }),
|
||||
{
|
||||
title: 'Cached Data',
|
||||
description: 'Retrieves cached data by key'
|
||||
},
|
||||
async (uri, { key }, env) => {
|
||||
if (!env.CACHE) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: 'Cache not configured'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const value = await env.CACHE.get(key as string);
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: value || `Key "${key}" not found`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// PROMPTS
|
||||
// ============================================================================
|
||||
|
||||
server.registerPrompt(
|
||||
'greeting',
|
||||
{
|
||||
description: 'Generates a friendly greeting prompt'
|
||||
},
|
||||
async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Hello! I\'m an AI assistant connected to a full-featured MCP server. I can help you search databases, manage cache, and analyze data. What would you like to do?'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'analyze-data',
|
||||
{
|
||||
description: 'Prompts the user to analyze data from the server'
|
||||
},
|
||||
async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'I have access to database search and caching tools. Please tell me what data you\'d like me to analyze, and I\'ll use the available tools to fetch and process it for you.'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'help',
|
||||
{
|
||||
description: 'Shows available tools and resources'
|
||||
},
|
||||
async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `# Available Capabilities
|
||||
|
||||
## Tools
|
||||
- **search-database**: Search database tables
|
||||
- **cache-set**: Store data in cache
|
||||
- **summarize-text**: Summarize text content
|
||||
|
||||
## Resources
|
||||
- **config://app**: Server configuration
|
||||
- **stats://server**: Server statistics
|
||||
- **data://{key}**: Cached data by key
|
||||
|
||||
## Prompts
|
||||
- **greeting**: Friendly introduction
|
||||
- **analyze-data**: Data analysis prompt
|
||||
- **help**: This help message
|
||||
|
||||
How can I assist you today?`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// HTTP SETUP
|
||||
// ============================================================================
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
name: 'full-mcp-server',
|
||||
version: '1.0.0',
|
||||
mcp_endpoint: '/mcp',
|
||||
capabilities: {
|
||||
tools: ['search-database', 'cache-set', 'summarize-text'],
|
||||
resources: ['config://app', 'stats://server', 'data://{key}'],
|
||||
prompts: ['greeting', 'analyze-data', 'help']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
|
||||
export default app;
|
||||
315
templates/resource-server.ts
Normal file
315
templates/resource-server.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Resource-Server MCP Template
|
||||
*
|
||||
* An MCP server focused on exposing resources (static and dynamic data).
|
||||
* Resources are URI-addressed data that LLMs can query.
|
||||
*
|
||||
* Setup:
|
||||
* 1. npm install @modelcontextprotocol/sdk hono zod
|
||||
* 2. npm install -D @cloudflare/workers-types wrangler typescript
|
||||
* 3. Configure bindings in wrangler.jsonc (D1, KV, etc.)
|
||||
* 4. wrangler deploy
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
type Env = {
|
||||
DB?: D1Database;
|
||||
CACHE?: KVNamespace;
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'resource-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Resource 1: Static Configuration
|
||||
server.registerResource(
|
||||
'config',
|
||||
new ResourceTemplate('config://app', { list: undefined }),
|
||||
{
|
||||
title: 'Application Configuration',
|
||||
description: 'Returns application configuration and metadata'
|
||||
},
|
||||
async (uri) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
features: ['static-resources', 'dynamic-resources', 'd1-integration'],
|
||||
lastUpdated: new Date().toISOString()
|
||||
}, null, 2)
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// Resource 2: Environment Info
|
||||
server.registerResource(
|
||||
'environment',
|
||||
new ResourceTemplate('env://info', { list: undefined }),
|
||||
{
|
||||
title: 'Environment Information',
|
||||
description: 'Returns environment and deployment information'
|
||||
},
|
||||
async (uri, _, env) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
hasDatabase: !!env.DB,
|
||||
hasCache: !!env.CACHE,
|
||||
runtime: 'cloudflare-workers',
|
||||
timestamp: new Date().toISOString()
|
||||
}, null, 2)
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// Resource 3: Dynamic User Profile (with parameter)
|
||||
server.registerResource(
|
||||
'user-profile',
|
||||
new ResourceTemplate('user://{userId}', { list: undefined }),
|
||||
{
|
||||
title: 'User Profile',
|
||||
description: 'Returns user profile data by ID'
|
||||
},
|
||||
async (uri, { userId }, env) => {
|
||||
if (!env.DB) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Database not configured' }, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await env.DB
|
||||
.prepare('SELECT id, name, email, created_at FROM users WHERE id = ?')
|
||||
.bind(userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: `User ${userId} not found` }, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(user, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
error: `Failed to fetch user: ${(error as Error).message}`
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resource 4: Dynamic Data by Key (KV-backed)
|
||||
server.registerResource(
|
||||
'data',
|
||||
new ResourceTemplate('data://{key}', { list: undefined }),
|
||||
{
|
||||
title: 'Data by Key',
|
||||
description: 'Returns data from KV store by key'
|
||||
},
|
||||
async (uri, { key }, env) => {
|
||||
if (!env.CACHE) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: 'KV store not configured'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await env.CACHE.get(key as string);
|
||||
|
||||
if (!value) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: `Key "${key}" not found`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse as JSON, fallback to plain text
|
||||
let mimeType = 'text/plain';
|
||||
let text = value;
|
||||
|
||||
try {
|
||||
JSON.parse(value);
|
||||
mimeType = 'application/json';
|
||||
} catch {
|
||||
// Not JSON, keep as plain text
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType,
|
||||
text
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: `Error fetching key: ${(error as Error).message}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resource 5: List All Users (D1-backed)
|
||||
server.registerResource(
|
||||
'users-list',
|
||||
new ResourceTemplate('users://all', { list: undefined }),
|
||||
{
|
||||
title: 'All Users',
|
||||
description: 'Returns list of all users from database'
|
||||
},
|
||||
async (uri, _, env) => {
|
||||
if (!env.DB) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Database not configured' }, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await env.DB
|
||||
.prepare('SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT 100')
|
||||
.all();
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
count: result.results.length,
|
||||
users: result.results
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({
|
||||
error: `Failed to fetch users: ${(error as Error).message}`
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resource 6: Dynamic File Content (with path parameter)
|
||||
server.registerResource(
|
||||
'file',
|
||||
new ResourceTemplate('file://{path}', { list: undefined }),
|
||||
{
|
||||
title: 'File Content',
|
||||
description: 'Returns content of a file by path (simulated)'
|
||||
},
|
||||
async (uri, { path }) => {
|
||||
// In production, you might fetch from R2, D1, or external storage
|
||||
const simulatedFiles: Record<string, string> = {
|
||||
'readme.md': '# README\n\nWelcome to the resource server!',
|
||||
'config.json': JSON.stringify({ setting1: 'value1', setting2: 'value2' }, null, 2),
|
||||
'data.txt': 'Sample text file content'
|
||||
};
|
||||
|
||||
const content = simulatedFiles[path as string];
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType: 'text/plain',
|
||||
text: `File "${path}" not found. Available: ${Object.keys(simulatedFiles).join(', ')}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = (path as string).endsWith('.json')
|
||||
? 'application/json'
|
||||
: (path as string).endsWith('.md')
|
||||
? 'text/markdown'
|
||||
: 'text/plain';
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
mimeType,
|
||||
text: content
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// HTTP endpoint setup
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
resources: [
|
||||
'config://app',
|
||||
'env://info',
|
||||
'user://{userId}',
|
||||
'data://{key}',
|
||||
'users://all',
|
||||
'file://{path}'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
|
||||
export default app;
|
||||
283
templates/tool-server.ts
Normal file
283
templates/tool-server.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Tool-Server MCP Template
|
||||
*
|
||||
* An MCP server focused on exposing multiple tools for LLM use.
|
||||
* Examples include API integrations, calculations, and data transformations.
|
||||
*
|
||||
* Setup:
|
||||
* 1. npm install @modelcontextprotocol/sdk hono zod
|
||||
* 2. npm install -D @cloudflare/workers-types wrangler typescript
|
||||
* 3. Add environment variables to wrangler.jsonc or .dev.vars
|
||||
* 4. wrangler deploy
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Env = {
|
||||
// API keys for external services
|
||||
WEATHER_API_KEY?: string;
|
||||
// Database (optional)
|
||||
DB?: D1Database;
|
||||
// Cache (optional)
|
||||
CACHE?: KVNamespace;
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'tool-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Tool 1: Weather API Integration
|
||||
server.registerTool(
|
||||
'get-weather',
|
||||
{
|
||||
description: 'Fetches current weather data for a city using OpenWeatherMap API',
|
||||
inputSchema: z.object({
|
||||
city: z.string().describe('City name (e.g., "London", "New York")'),
|
||||
units: z.enum(['metric', 'imperial']).default('metric').describe('Temperature units')
|
||||
})
|
||||
},
|
||||
async ({ city, units }, env) => {
|
||||
if (!env.WEATHER_API_KEY) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Error: WEATHER_API_KEY not configured'
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${units}&appid=${env.WEATHER_API_KEY}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
main: { temp: number; feels_like: number; humidity: number };
|
||||
weather: Array<{ description: string }>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const tempUnit = units === 'metric' ? '°C' : '°F';
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Weather in ${data.name}:
|
||||
- Temperature: ${data.main.temp}${tempUnit}
|
||||
- Feels like: ${data.main.feels_like}${tempUnit}
|
||||
- Humidity: ${data.main.humidity}%
|
||||
- Conditions: ${data.weather[0].description}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to fetch weather: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 2: Calculator
|
||||
server.registerTool(
|
||||
'calculate',
|
||||
{
|
||||
description: 'Performs mathematical calculations',
|
||||
inputSchema: z.object({
|
||||
operation: z.enum(['add', 'subtract', 'multiply', 'divide', 'power', 'sqrt']).describe('Mathematical operation'),
|
||||
a: z.number().describe('First number'),
|
||||
b: z.number().optional().describe('Second number (not required for sqrt)')
|
||||
})
|
||||
},
|
||||
async ({ operation, a, b }) => {
|
||||
let result: number;
|
||||
|
||||
try {
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
result = a + (b || 0);
|
||||
break;
|
||||
case 'subtract':
|
||||
result = a - (b || 0);
|
||||
break;
|
||||
case 'multiply':
|
||||
result = a * (b || 1);
|
||||
break;
|
||||
case 'divide':
|
||||
if (b === 0) throw new Error('Division by zero');
|
||||
result = a / (b || 1);
|
||||
break;
|
||||
case 'power':
|
||||
result = Math.pow(a, b || 2);
|
||||
break;
|
||||
case 'sqrt':
|
||||
if (a < 0) throw new Error('Cannot take square root of negative number');
|
||||
result = Math.sqrt(a);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Result: ${result}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Calculation error: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 3: Text Analysis
|
||||
server.registerTool(
|
||||
'analyze-text',
|
||||
{
|
||||
description: 'Analyzes text and returns statistics',
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('Text to analyze')
|
||||
})
|
||||
},
|
||||
async ({ text }) => {
|
||||
const words = text.trim().split(/\s+/);
|
||||
const chars = text.length;
|
||||
const charsNoSpaces = text.replace(/\s/g, '').length;
|
||||
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||||
const paragraphs = text.split(/\n\n+/).filter(p => p.trim().length > 0).length;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Text Analysis:
|
||||
- Words: ${words.length}
|
||||
- Characters: ${chars}
|
||||
- Characters (no spaces): ${charsNoSpaces}
|
||||
- Sentences: ${sentences}
|
||||
- Paragraphs: ${paragraphs}
|
||||
- Average word length: ${(charsNoSpaces / words.length).toFixed(2)} chars`
|
||||
}]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 4: JSON Formatter
|
||||
server.registerTool(
|
||||
'format-json',
|
||||
{
|
||||
description: 'Formats and validates JSON data',
|
||||
inputSchema: z.object({
|
||||
json: z.string().describe('JSON string to format'),
|
||||
indent: z.number().default(2).describe('Number of spaces for indentation')
|
||||
})
|
||||
},
|
||||
async ({ json, indent }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const formatted = JSON.stringify(parsed, null, indent);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Formatted JSON:\n\`\`\`json\n${formatted}\n\`\`\``
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Invalid JSON: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 5: URL Parser
|
||||
server.registerTool(
|
||||
'parse-url',
|
||||
{
|
||||
description: 'Parses a URL and returns its components',
|
||||
inputSchema: z.object({
|
||||
url: z.string().url().describe('URL to parse')
|
||||
})
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `URL Components:
|
||||
- Protocol: ${parsed.protocol}
|
||||
- Host: ${parsed.host}
|
||||
- Hostname: ${parsed.hostname}
|
||||
- Port: ${parsed.port || 'default'}
|
||||
- Pathname: ${parsed.pathname}
|
||||
- Search: ${parsed.search || 'none'}
|
||||
- Hash: ${parsed.hash || 'none'}
|
||||
- Origin: ${parsed.origin}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Invalid URL: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// HTTP endpoint setup
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
name: 'tool-server',
|
||||
version: '1.0.0',
|
||||
tools: [
|
||||
'get-weather',
|
||||
'calculate',
|
||||
'analyze-text',
|
||||
'format-json',
|
||||
'parse-url'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/mcp', async (c) => {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
c.res.raw.on('close', () => transport.close());
|
||||
|
||||
// Pass env to server context
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
|
||||
|
||||
return c.body(null);
|
||||
});
|
||||
|
||||
export default app;
|
||||
173
templates/wrangler.jsonc
Normal file
173
templates/wrangler.jsonc
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
// Cloudflare Workers configuration for MCP Server
|
||||
// Documentation: https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||
|
||||
"name": "my-mcp-server",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-10-28",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
|
||||
// Account ID (get from: wrangler whoami)
|
||||
// "account_id": "YOUR_ACCOUNT_ID",
|
||||
|
||||
// Environment variables (non-sensitive)
|
||||
// For sensitive values, use wrangler secret put or .dev.vars file
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production",
|
||||
"LOG_LEVEL": "info"
|
||||
},
|
||||
|
||||
// D1 Database bindings
|
||||
// Create: wrangler d1 create my-db
|
||||
// "d1_databases": [
|
||||
// {
|
||||
// "binding": "DB",
|
||||
// "database_name": "my-db",
|
||||
// "database_id": "YOUR_DATABASE_ID"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// KV namespace bindings
|
||||
// Create: wrangler kv namespace create CACHE
|
||||
// Create: wrangler kv namespace create MCP_API_KEYS
|
||||
// "kv_namespaces": [
|
||||
// {
|
||||
// "binding": "CACHE",
|
||||
// "id": "YOUR_KV_NAMESPACE_ID"
|
||||
// },
|
||||
// {
|
||||
// "binding": "MCP_API_KEYS",
|
||||
// "id": "YOUR_API_KEYS_NAMESPACE_ID"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// R2 bucket bindings
|
||||
// Create: wrangler r2 bucket create my-bucket
|
||||
// "r2_buckets": [
|
||||
// {
|
||||
// "binding": "BUCKET",
|
||||
// "bucket_name": "my-bucket"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Vectorize index bindings
|
||||
// Create: wrangler vectorize create my-index --dimensions=768 --metric=cosine
|
||||
// "vectorize": [
|
||||
// {
|
||||
// "binding": "VECTORIZE",
|
||||
// "index_name": "my-index"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Workers AI binding
|
||||
// "ai": {
|
||||
// "binding": "AI"
|
||||
// },
|
||||
|
||||
// Queue bindings
|
||||
// Create: wrangler queues create my-queue
|
||||
// "queues": {
|
||||
// "producers": [
|
||||
// {
|
||||
// "binding": "MY_QUEUE",
|
||||
// "queue": "my-queue"
|
||||
// }
|
||||
// ],
|
||||
// "consumers": [
|
||||
// {
|
||||
// "queue": "my-queue",
|
||||
// "max_batch_size": 10,
|
||||
// "max_batch_timeout": 30
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
|
||||
// Durable Objects bindings
|
||||
// "durable_objects": {
|
||||
// "bindings": [
|
||||
// {
|
||||
// "name": "MY_DURABLE_OBJECT",
|
||||
// "class_name": "MyDurableObject",
|
||||
// "script_name": "my-worker"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
|
||||
// Service bindings (call other Workers)
|
||||
// "services": [
|
||||
// {
|
||||
// "binding": "OTHER_SERVICE",
|
||||
// "service": "other-worker",
|
||||
// "environment": "production"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Analytics Engine binding
|
||||
// "analytics_engine_datasets": [
|
||||
// {
|
||||
// "binding": "ANALYTICS"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Hyperdrive binding (Postgres connection pooling)
|
||||
// Create: wrangler hyperdrive create my-hyperdrive --connection-string="postgres://..."
|
||||
// "hyperdrive": [
|
||||
// {
|
||||
// "binding": "HYPERDRIVE",
|
||||
// "id": "YOUR_HYPERDRIVE_ID"
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Environment-specific configurations
|
||||
"env": {
|
||||
"staging": {
|
||||
"name": "my-mcp-server-staging",
|
||||
"vars": {
|
||||
"ENVIRONMENT": "staging"
|
||||
}
|
||||
// Override bindings for staging
|
||||
// "d1_databases": [...]
|
||||
},
|
||||
"production": {
|
||||
"name": "my-mcp-server-production",
|
||||
"vars": {
|
||||
"ENVIRONMENT": "production"
|
||||
}
|
||||
// Override bindings for production
|
||||
// "d1_databases": [...]
|
||||
}
|
||||
},
|
||||
|
||||
// Build configuration
|
||||
"build": {
|
||||
"command": "npm run build"
|
||||
},
|
||||
|
||||
// Observability (optional)
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
// Limits (optional, defaults shown)
|
||||
// "limits": {
|
||||
// "cpu_ms": 50
|
||||
// },
|
||||
|
||||
// Placement (optional, for advanced routing)
|
||||
// "placement": {
|
||||
// "mode": "smart"
|
||||
// },
|
||||
|
||||
// Custom domains (configure in dashboard first)
|
||||
// "routes": [
|
||||
// {
|
||||
// "pattern": "mcp.example.com/*",
|
||||
// "custom_domain": true
|
||||
// }
|
||||
// ],
|
||||
|
||||
// Cron triggers (scheduled tasks)
|
||||
// "triggers": {
|
||||
// "crons": ["0 0 * * *"] // Run daily at midnight
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user