@hellocoop/admin-mcp
Version:
Model Context Protocol (MCP) for HellΕ Admin API.
799 lines (699 loc) β’ 23.6 kB
JavaScript
// Mock Admin Server for MCP Testing
// Implements all Admin API endpoints used by MCP tools
import fastify from 'fastify';
import multipart from '@fastify/multipart';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
const PORT = process.env.MOCK_ADMIN_PORT || 3333;
const HOST = process.env.MOCK_ADMIN_HOST || '0.0.0.0';
// Mock JWT secret (not validated, just decoded)
const MOCK_JWT_SECRET = 'mock-secret-for-testing-only';
// Mock data store
const mockData = {
users: {
'user123': {
sub: 'user123',
name: 'Test User',
email: 'test@example.com',
publishers: ['pub123', 'pub456', 'pub789']
}
},
publishers: {
'pub123': {
id: 'pub123',
name: "Test User's Team",
owner: 'user123',
applications: ['app123', 'app456']
},
'pub456': {
id: 'pub456',
name: "Hello Identity Co-op",
owner: 'user123',
applications: ['app789', 'app101']
},
'pub789': {
id: 'pub789',
name: "Dick Hardt's Team",
owner: 'user123',
applications: ['app202', 'app303']
}
},
applications: {
'app123': {
id: 'app123',
name: 'Test Application',
publisher_id: 'pub123',
tos_uri: null,
pp_uri: null,
image_uri: null,
dark_image_uri: null,
device_code: false,
web: {
dev: {
localhost: true,
"127.0.0.1": true,
wildcard_domain: false,
redirect_uris: ['http://localhost:3000/callback']
},
prod: {
redirect_uris: []
}
},
createdBy: 'mcp'
},
'app456': {
id: 'app456',
name: 'Another App',
publisher_id: 'pub123',
tos_uri: 'https://example.com/tos',
pp_uri: 'https://example.com/privacy',
image_uri: 'https://example.com/logo.png',
device_code: true,
web: {
dev: {
localhost: true,
"127.0.0.1": false,
wildcard_domain: true,
redirect_uris: ['http://localhost:8080/auth']
},
prod: {
redirect_uris: ['https://myapp.com/callback']
}
}
},
'app789': {
id: 'app789',
name: 'Identity Co-op Portal',
publisher_id: 'pub456',
tos_uri: 'https://hello.coop/tos',
pp_uri: 'https://hello.coop/privacy',
image_uri: null,
dark_image_uri: null,
device_code: false,
web: {
dev: {
localhost: true,
"127.0.0.1": true,
wildcard_domain: false,
redirect_uris: ['http://localhost:4000/callback']
},
prod: {
redirect_uris: ['https://portal.hello.coop/auth']
}
},
createdBy: 'admin'
},
'app101': {
id: 'app101',
name: 'Hello SDK Demo',
publisher_id: 'pub456',
tos_uri: null,
pp_uri: null,
image_uri: 'https://logos.hello.coop/hello-logo.png',
dark_image_uri: 'https://logos.hello.coop/hello-logo-dark.png',
device_code: true,
web: {
dev: {
localhost: true,
"127.0.0.1": true,
wildcard_domain: true,
redirect_uris: ['http://localhost:5000/callback']
},
prod: {
redirect_uris: ['https://demo.hello.coop/callback']
}
},
createdBy: 'quickstart'
},
'app202': {
id: 'app202',
name: 'Personal Project Alpha',
publisher_id: 'pub789',
tos_uri: null,
pp_uri: null,
image_uri: null,
dark_image_uri: null,
device_code: false,
web: {
dev: {
localhost: true,
"127.0.0.1": false,
wildcard_domain: false,
redirect_uris: ['http://localhost:3001/auth']
},
prod: {
redirect_uris: ['https://alpha.dickhardt.org/callback']
}
},
createdBy: 'mcp'
},
'app303': {
id: 'app303',
name: 'Experimental OAuth Client',
publisher_id: 'pub789',
tos_uri: 'https://dickhardt.org/tos',
pp_uri: 'https://dickhardt.org/privacy',
image_uri: null,
dark_image_uri: null,
device_code: true,
web: {
dev: {
localhost: false,
"127.0.0.1": false,
wildcard_domain: false,
redirect_uris: ['http://localhost:8000/oauth/callback']
},
prod: {
redirect_uris: ['https://experiment.dickhardt.org/oauth/callback']
}
},
createdBy: 'mcp'
}
}
};
// Storage for uploaded logo data (for testing validation)
const uploadedLogos = {};
// Create Fastify instance
const app = fastify({
logger: {
level: 'info',
transport: {
target: 'pino-pretty'
}
}
});
// Register multipart plugin for file uploads
await app.register(multipart);
/**
* Generate a mock JWT token
* @param {Object} payload - JWT payload
* @param {Object} options - Token options
* @returns {string} - JWT token
*/
function generateMockToken(payload, options = {}) {
const defaultPayload = {
iss: 'https://issuer.hello.coop',
aud: 'https://admin.hello.coop',
scope: ['mcp'], // MCP server expects scope as array
jti: crypto.randomUUID(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (options.expiresIn || 3600), // 1 hour default
...payload
};
return jwt.sign(defaultPayload, MOCK_JWT_SECRET, { algorithm: 'HS256' });
}
/**
* Validate JWT token (mock validation - doesn't verify signature)
* @param {string} token - JWT token
* @returns {Object|null} - Decoded payload or null if invalid
*/
function validateMockToken(token) {
try {
// Decode without verification for mock purposes
const decoded = jwt.decode(token);
if (!decoded) {
return null;
}
// Check expiration
if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
return { expired: true, payload: decoded };
}
// Check required fields
if (!decoded.sub || !decoded.scope || !Array.isArray(decoded.scope) || !decoded.scope.includes('mcp')) {
return null;
}
return { valid: true, payload: decoded };
} catch (error) {
return null;
}
}
/**
* Creates a properly formatted WWW-Authenticate header (matching MCP server spec)
* @param {Object} validationResult - Validation result with error details
* @returns {string} - Formatted WWW-Authenticate header value
*/
function createWWWAuthenticateHeader(validationResult) {
const realm = 'Hello Admin API';
const resourceMetadata = `https://admin.hello.coop/.well-known/oauth-protected-resource`;
let headerParts = [
`realm="${realm}"`,
`error="${validationResult.error}"`,
`error_description="${validationResult.error_description}"`,
`scope="mcp"`,
`resource_metadata="${resourceMetadata}"`
];
return `Bearer ${headerParts.join(', ')}`;
}
/**
* Authentication middleware
*/
async function authenticate(request, reply) {
const authHeader = request.headers.authorization;
if (!authHeader) {
const validationResult = {
error: 'invalid_request',
error_description: 'Authorization header required'
};
return reply.code(401)
.header('WWW-Authenticate', createWWWAuthenticateHeader(validationResult))
.send({
error: validationResult.error,
error_description: validationResult.error_description
});
}
const bearerMatch = authHeader.match(/^bearer\s+(.+)$/i);
if (!bearerMatch) {
const validationResult = {
error: 'invalid_request',
error_description: 'Bearer token required'
};
return reply.code(401)
.header('WWW-Authenticate', createWWWAuthenticateHeader(validationResult))
.send({
error: validationResult.error,
error_description: validationResult.error_description
});
}
const token = bearerMatch[1].trim();
const validation = validateMockToken(token);
if (!validation) {
const validationResult = {
error: 'invalid_token',
error_description: 'Invalid token format'
};
return reply.code(401)
.header('WWW-Authenticate', createWWWAuthenticateHeader(validationResult))
.send({
error: validationResult.error,
error_description: validationResult.error_description
});
}
if (validation.expired) {
const validationResult = {
error: 'invalid_token',
error_description: 'Token has expired'
};
return reply.code(401)
.header('WWW-Authenticate', createWWWAuthenticateHeader(validationResult))
.send({
error: validationResult.error,
error_description: validationResult.error_description
});
}
if (!validation.valid) {
const validationResult = {
error: 'insufficient_scope',
error_description: 'Token does not have required scope'
};
return reply.code(403)
.header('WWW-Authenticate', createWWWAuthenticateHeader(validationResult))
.send({
error: validationResult.error,
error_description: validationResult.error_description
});
}
// Add user info to request
request.user = validation.payload;
request.userId = validation.payload.sub;
}
// Add authentication hook for protected routes
app.addHook('preHandler', async (request, reply) => {
// Skip auth for health, token, and test asset endpoints
if (request.url === '/health' ||
request.url === '/token' ||
request.url.startsWith('/token/') ||
request.url.startsWith('/test-assets/') ||
request.url.startsWith('/test-data/')) {
return;
}
// All other routes require authentication
await authenticate(request, reply);
});
// Health check endpoint (no auth required)
app.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Token generation endpoints (for testing)
app.post('/token/valid', async (request, reply) => {
const { sub = 'user123', expiresIn = 3600 } = request.body || {};
const token = generateMockToken({ sub }, { expiresIn });
return { access_token: token, token_type: 'Bearer', expires_in: expiresIn };
});
app.post('/token/expired', async (request, reply) => {
const { sub = 'user123' } = request.body || {};
const token = generateMockToken({ sub }, { expiresIn: -3600 }); // Expired 1 hour ago
return { access_token: token, token_type: 'Bearer', expires_in: -3600 };
});
// Profile endpoints
app.get('/api/v1/profile', async (request, reply) => {
const user = mockData.users[request.userId];
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
// Get the first publisher as current publisher (matching real API behavior)
const currentPublisherId = user.publishers[0];
const currentPublisher = mockData.publishers[currentPublisherId];
// Get all applications for current publisher
const currentPublisherApplications = currentPublisher?.applications?.map(appId => mockData.applications[appId]).filter(Boolean) || [];
// Publishers list (basic info only, no applications)
const publishers = user.publishers.map(pubId => {
const publisher = mockData.publishers[pubId];
if (!publisher) return null;
return {
type: "publisher",
id: publisher.id,
name: publisher.name,
role: "admin",
createdAt: "2024-07-03T16:51:30.676Z"
};
}).filter(Boolean);
return {
profile: {
id: user.sub,
type: "user",
name: user.name,
email: user.email,
picture: "https://pictures.hello.coop/r/test-picture.png",
createdAt: "2024-07-01T23:28:18.197Z",
lastUpdated: "2025-06-30T21:37:00.963Z"
},
isNewAdmin: false,
publishers,
currentPublisher: currentPublisher ? {
profile: {
type: "publisher",
id: currentPublisher.id,
name: currentPublisher.name,
createdAt: "2024-07-03T16:51:30.676Z"
},
applications: currentPublisherApplications.map(app => ({
...app,
type: "application",
publisher: currentPublisher.id,
createdAt: "2024-07-03T16:51:35.286Z",
createdBy: "mcp"
})),
members: {
admins: [{
id: user.sub,
type: "user",
name: user.name,
email: user.email,
picture: "https://pictures.hello.coop/r/test-picture.png",
createdAt: "2024-07-01T23:28:18.197Z"
}],
testers: []
}
} : null,
notifications: []
};
});
app.get('/api/v1/profile/:publisherId', async (request, reply) => {
const { publisherId } = request.params;
const publisher = mockData.publishers[publisherId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
const applications = publisher.applications.map(appId => mockData.applications[appId]).filter(Boolean);
return {
publisher: {
...publisher,
applications
}
};
});
// Direct application lookup endpoint
app.get('/api/v1/applications/:applicationId', async (request, reply) => {
const { applicationId } = request.params;
const application = mockData.applications[applicationId];
if (!application) {
return reply.code(404).send({ error: 'Application not found' });
}
return {
...application,
type: "application",
createdAt: "2024-07-03T16:51:35.286Z",
createdBy: "mcp"
};
});
// Publisher endpoints
app.post('/api/v1/publishers', async (request, reply) => {
const { name } = request.body;
const publisherId = `pub${Date.now()}`;
const newPublisher = {
id: publisherId,
name: name || `${mockData.users[request.userId]?.name || 'User'}'s Team`,
owner: request.userId,
applications: []
};
mockData.publishers[publisherId] = newPublisher;
mockData.users[request.userId].publishers.push(publisherId);
return newPublisher;
});
app.put('/api/v1/publishers/:publisherId', async (request, reply) => {
const { publisherId } = request.params;
const { name } = request.body;
const publisher = mockData.publishers[publisherId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
publisher.name = name;
return publisher;
});
app.get('/api/v1/publishers/:publisherId', async (request, reply) => {
const { publisherId } = request.params;
const publisher = mockData.publishers[publisherId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
const applications = publisher.applications.map(appId => {
const app = mockData.applications[appId];
if (!app) return null;
return {
...app,
type: "application",
publisher: publisher.id,
publisherName: publisher.name,
createdAt: "2024-07-03T16:51:35.286Z",
teamId: publisher.id,
teamName: publisher.name
};
}).filter(Boolean);
// Match the real Admin server's response structure
return {
profile: {
type: "publisher",
id: publisher.id,
name: publisher.name,
createdAt: "2024-07-03T16:51:35.286Z"
},
applications,
members: {
admins: [{
id: request.userId,
name: mockData.users[request.userId]?.name || "Test User",
email: mockData.users[request.userId]?.email || "test@example.com"
}],
testers: []
},
role: "admin"
};
});
// Application endpoints
app.post('/api/v1/publishers/:publisherId/applications', async (request, reply) => {
const { publisherId } = request.params;
const publisher = mockData.publishers[publisherId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
const applicationId = `app${Date.now()}`;
const newApplication = {
id: applicationId,
publisher_id: publisherId,
name: request.body.name || `${mockData.users[request.userId]?.name || 'User'}'s MCP Created App`,
tos_uri: request.body.tos_uri || null,
pp_uri: request.body.pp_uri || null,
image_uri: request.body.image_uri || null,
dark_image_uri: request.body.dark_image_uri || null,
device_code: request.body.device_code || false,
web: request.body.web || {
dev: {
localhost: true,
"127.0.0.1": true,
wildcard_domain: false,
redirect_uris: []
},
prod: {
redirect_uris: []
}
},
createdBy: request.body.createdBy || 'mcp'
};
mockData.applications[applicationId] = newApplication;
publisher.applications.push(applicationId);
return newApplication;
});
app.get('/api/v1/publishers/:publisherId/applications/:applicationId', async (request, reply) => {
const { publisherId, applicationId } = request.params;
const publisher = mockData.publishers[publisherId];
const application = mockData.applications[applicationId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
if (!application || application.publisher_id !== publisherId) {
return reply.code(404).send({ error: 'Application not found' });
}
return application;
});
app.put('/api/v1/publishers/:publisherId/applications/:applicationId', async (request, reply) => {
const { publisherId, applicationId } = request.params;
const publisher = mockData.publishers[publisherId];
const application = mockData.applications[applicationId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
if (!application || application.publisher_id !== publisherId) {
return reply.code(404).send({ error: 'Application not found' });
}
// Update application with new data
Object.assign(application, request.body);
application.id = applicationId; // Ensure ID doesn't change
application.publisher_id = publisherId; // Ensure publisher_id doesn't change
return application;
});
// Secrets endpoint
app.post('/api/v1/publishers/:publisherId/applications/:applicationId/secrets', async (request, reply) => {
const { publisherId, applicationId } = request.params;
const publisher = mockData.publishers[publisherId];
const application = mockData.applications[applicationId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
if (!application || application.publisher_id !== publisherId) {
return reply.code(404).send({ error: 'Application not found' });
}
const { hash, salt } = request.body;
return {
message: 'Secret created successfully',
hash,
salt,
created_at: new Date().toISOString()
};
});
// Logo upload endpoint
app.post('/api/v1/publishers/:publisherId/applications/:applicationId/logo', async (request, reply) => {
const { publisherId, applicationId } = request.params;
const publisher = mockData.publishers[publisherId];
const application = mockData.applications[applicationId];
if (!publisher || publisher.owner !== request.userId) {
return reply.code(404).send({ error: 'Publisher not found' });
}
if (!application || application.publisher_id !== publisherId) {
return reply.code(404).send({ error: 'Application not found' });
}
console.log(`π Mock Admin Server: Logo upload request received for ${applicationId}`);
console.log(` Content-Type: ${request.headers['content-type']}`);
try {
// Handle multipart form data upload
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file uploaded' });
}
// Read the uploaded file data
const chunks = [];
for await (const chunk of data.file) {
chunks.push(chunk);
}
const fileBuffer = Buffer.concat(chunks);
const uploadedData = {
filename: data.filename,
mimetype: data.mimetype,
size: fileBuffer.length,
data: fileBuffer.toString('base64'),
uploadedAt: new Date().toISOString(),
contentType: request.headers['content-type']
};
// Store the uploaded data for test verification
uploadedLogos[applicationId] = uploadedData;
console.log(` File uploaded: ${data.filename} (${data.mimetype}, ${fileBuffer.length} bytes)`);
console.log(` Stored data for application: ${applicationId}`);
// Return success with a generic logo URL
const logoUrl = `https://mock-cdn.hello.coop/logos/${applicationId}-${Date.now()}.png`;
return {
image_uri: logoUrl,
message: 'Logo uploaded successfully'
};
} catch (error) {
console.error('Error processing logo upload:', error);
return reply.code(500).send({ error: 'Failed to process logo upload' });
}
});
// Static file endpoint for testing logo URL fetching
app.get('/test-assets/playground-logo.png', async (request, reply) => {
try {
// In Docker, we're in /usr/src/mcp, so test files are in ./test/
const logoPath = path.join(process.cwd(), 'test', 'playground-logo.png');
const logoBuffer = fs.readFileSync(logoPath);
reply.type('image/png');
return logoBuffer;
} catch (error) {
console.error('Error serving playground logo:', error);
console.error('Tried path:', path.join(process.cwd(), 'test', 'playground-logo.png'));
return reply.code(404).send({ error: 'Logo file not found' });
}
});
// Test endpoint to retrieve uploaded logo data for validation (no auth required)
app.get('/test-data/uploaded-logo/:applicationId', { preHandler: [] }, async (request, reply) => {
const { applicationId } = request.params;
if (!uploadedLogos[applicationId]) {
return reply.code(404).send({ error: 'No uploaded logo data found for this application' });
}
return {
applicationId,
uploadedData: uploadedLogos[applicationId]
};
});
// Start server
async function start() {
try {
await app.listen({ port: PORT, host: HOST });
console.log(`π Mock Admin Server listening on http://${HOST}:${PORT}`);
console.log(`π Health check: http://${HOST}:${PORT}/health`);
console.log(`π Generate valid token: POST http://${HOST}:${PORT}/token/valid`);
console.log(`β° Generate expired token: POST http://${HOST}:${PORT}/token/expired`);
console.log(`π§ Set HELLO_ADMIN=http://${HOST}:${PORT} to use this mock server`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
// Graceful shutdown handlers
const gracefulShutdown = async (signal) => {
console.log(`\nπ Received ${signal}, shutting down mock admin server...`);
try {
await app.close();
console.log('β
Mock Admin Server shut down successfully');
process.exit(0);
} catch (err) {
console.error('β Error during shutdown:', err);
process.exit(1);
}
};
// Handle shutdown signals
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('β Uncaught Exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('β Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
start();
// Export for testing
export { app, generateMockToken, validateMockToken, mockData };