Complete guide for contributing and extending the MCP server
Project structure, development workflow, and best practices
Required:
Recommended:
# Clone repository
git clone https://github.com/Hack23/European-Parliament-MCP-Server.git
cd European-Parliament-MCP-Server
# Install dependencies
npm install
# Build project
npm run build
# Run tests
npm test
# Start development server
npm run dev
# Install recommended tools
npm install -g tsx vitest
# Setup git hooks (optional)
npm run prepare
# Verify setup
npm run type-check
npm run lint
npm test
European-Parliament-MCP-Server/
โโโ .github/ # GitHub configuration
โ โโโ workflows/ # CI/CD workflows
โ โ โโโ test-and-report.yml # Unit tests + coverage
โ โ โโโ integration-tests.yml # E2E + performance tests
โ โ โโโ release.yml # Automated releases
โ โโโ agents/ # Custom Copilot agents
โ โโโ skills/ # Reusable skill patterns
โ
โโโ docs/ # Documentation
โ โโโ API_USAGE_GUIDE.md # Tool usage documentation
โ โโโ ARCHITECTURE_DIAGRAMS.md # Visual architecture
โ โโโ TROUBLESHOOTING.md # Common issues
โ โโโ DEVELOPER_GUIDE.md # This file
โ โโโ DEPLOYMENT_GUIDE.md # Deployment instructions
โ โโโ PERFORMANCE_GUIDE.md # Optimization strategies
โ
โโโ src/ # Source code
โ โโโ index.ts # Server entry point
โ โ
โ โโโ tools/ # MCP tool implementations
โ โ โโโ getMEPs.ts
โ โ โโโ getMEPDetails.ts
โ โ โโโ getPlenarySessions.ts
โ โ โโโ getVotingRecords.ts
โ โ โโโ searchDocuments.ts
โ โ โโโ getCommitteeInfo.ts
โ โ โโโ getParliamentaryQuestions.ts
โ โ โโโ analyzeVotingPatterns.ts
โ โ โโโ trackLegislation.ts
โ โ โโโ generateReport.ts
โ โ
โ โโโ clients/ # External API clients
โ โ โโโ europeanParliamentClient.ts
โ โ
โ โโโ schemas/ # Zod validation schemas
โ โ โโโ europeanParliament.ts
โ โ
โ โโโ types/ # TypeScript type definitions
โ โ โโโ europeanParliament.ts
โ โ
โ โโโ utils/ # Utility functions
โ โ โโโ cache.ts
โ โ โโโ rateLimiter.ts
โ โ โโโ logger.ts
โ โ
โ โโโ services/ # Business services
โ โโโ MetricsService.ts
โ
โโโ tests/ # Test files
โ โโโ integration/ # Integration tests
โ โโโ e2e/ # End-to-end tests
โ โโโ performance/ # Performance benchmarks
โ โโโ helpers/ # Test utilities
โ
โโโ dist/ # Compiled JavaScript (gitignored)
โโโ node_modules/ # Dependencies (gitignored)
โ
โโโ package.json # Project metadata
โโโ tsconfig.json # TypeScript configuration
โโโ vitest.config.ts # Test configuration
โโโ eslint.config.js # ESLint configuration
โโโ README.md # Project overview
| Directory | Purpose | File Types |
|---|---|---|
src/tools/ |
MCP tool implementations | .ts, .test.ts |
src/clients/ |
External API clients | .ts, .test.ts |
src/schemas/ |
Zod validation schemas | .ts |
src/types/ |
TypeScript type definitions | .ts |
src/utils/ |
Utility functions | .ts, .test.ts |
tests/integration/ |
Integration tests | .test.ts |
tests/e2e/ |
End-to-end MCP tests | .e2e.test.ts |
docs/ |
Documentation | .md |
# 1. Pull latest changes
git checkout main
git pull origin main
# 2. Create feature branch
git checkout -b feature/my-feature
# 3. Make changes
# Edit files...
# 4. Run tests continuously
npm run test:watch
# 5. Lint and format
npm run lint:fix
npm run format
# 6. Build
npm run build
# 7. Run full test suite
npm test
# 8. Commit changes
git add .
git commit -m "feat: add new feature"
# 9. Push to GitHub
git push origin feature/my-feature
# 10. Create Pull Request
# Build
npm run build # Production build
npm run build:watch # Watch mode
# Testing
npm test # All tests
npm run test:unit # Unit tests only
npm run test:integration # Integration tests
npm run test:e2e # End-to-end tests
npm run test:performance # Performance benchmarks
npm run test:coverage # Coverage report
npm run test:watch # Watch mode
# Code Quality
npm run lint # Check linting
npm run lint:fix # Fix linting issues
npm run format # Format with Prettier
npm run type-check # TypeScript type checking
# Other
npm run dev # Development server
npm run knip # Find unused exports
npm run test:licenses # Check license compliance
Create Zod schema in src/schemas/europeanParliament.ts:
/**
* Schema for new_tool input validation
*/
export const NewToolSchema = z.object({
param1: z.string().min(1).max(100),
param2: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
param3: z.number().int().min(1).max(100).default(50)
});
export type NewToolParams = z.infer<typeof NewToolSchema>;
Add types in src/types/europeanParliament.ts:
/**
* Result type for new_tool
*/
export interface NewToolResult {
id: string;
name: string;
data: unknown[];
metadata: {
timestamp: string;
source: string;
};
}
Create src/tools/newTool.ts:
/**
* MCP Tool: new_tool
*
* Brief description of what this tool does
*
* ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege)
*/
import { NewToolSchema } from '../schemas/europeanParliament.js';
import { epClient } from '../clients/europeanParliamentClient.js';
import type { NewToolResult } from '../types/europeanParliament.js';
/**
* New tool handler
*
* @param args - Tool arguments
* @returns MCP tool result
*
* @example
* ```json
* {
* "param1": "value1",
* "param2": "2024-01-01"
* }
* ```
*/
export async function handleNewTool(
args: unknown
): Promise<{ content: { type: string; text: string }[] }> {
// Step 1: Validate input
const params = NewToolSchema.parse(args);
try {
// Step 2: Fetch data from EP API client
const result = await epClient.getNewData(params);
// Step 3: Transform to expected format
const transformed: NewToolResult = {
id: result.id,
name: result.name,
data: result.items,
metadata: {
timestamp: new Date().toISOString(),
source: 'European Parliament'
}
};
// Step 4: Return MCP-compliant response
return {
content: [{
type: 'text',
text: JSON.stringify(transformed, null, 2)
}]
};
} catch (error) {
// Step 5: Handle errors without exposing internal details
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to execute new_tool: ${errorMessage}`);
}
}
/**
* Tool metadata for MCP registration
*/
export const newToolMetadata = {
name: 'new_tool',
description: 'Brief description of what this tool does and what data it returns. Include key use cases.',
inputSchema: {
type: 'object' as const,
properties: {
param1: {
type: 'string',
description: 'Description of param1',
minLength: 1,
maxLength: 100
},
param2: {
type: 'string',
description: 'Optional date parameter (YYYY-MM-DD)',
pattern: '^\\d{4}-\\d{2}-\\d{2}$'
},
param3: {
type: 'number',
description: 'Page size (1-100)',
minimum: 1,
maximum: 100,
default: 50
}
},
required: ['param1']
}
};
Add to src/index.ts:
// Import tool
import { handleNewTool, newToolMetadata } from './tools/newTool.js';
// Register in tool handlers map
const toolHandlers: Record<string, (args: unknown) => Promise<any>> = {
get_meps: handleGetMEPs,
// ... other tools ...
new_tool: handleNewTool // Add new tool
};
// Register metadata in list_tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
getMEPsToolMetadata,
// ... other tools ...
newToolMetadata // Add new tool metadata
]
};
});
If needed, add method to src/clients/europeanParliamentClient.ts:
/**
* Get new data from European Parliament API
*/
async getNewData(params: {
param1: string;
param2?: string;
}): Promise<NewToolResult> {
const cacheKey = `newdata:${params.param1}:${params.param2 ?? 'all'}`;
// Check cache
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
// Rate limiting
await this.rateLimiter.waitForToken();
// Build URL
const url = new URL('/api/v2/newdata', this.baseUrl);
url.searchParams.append('param1', params.param1);
if (params.param2) {
url.searchParams.append('param2', params.param2);
}
// Fetch data
const response = await fetch(url.toString(), {
headers: {
'Accept': 'application/ld+json',
'User-Agent': 'European-Parliament-MCP-Server/1.0'
}
});
if (!response.ok) {
throw new Error(`EP API error: ${response.status}`);
}
const data = await response.json();
// Transform from JSON-LD to internal format
const result = this.transformNewData(data);
// Cache result
this.cache.set(cacheKey, result);
// Log access for audit
this.logger.info('Fetched new data', { param1: params.param1 });
return result;
}
/**
* Transform EP API JSON-LD to internal format
*/
private transformNewData(apiData: any): NewToolResult {
return {
id: this.toSafeString(apiData['id']),
name: this.toSafeString(apiData['label']),
data: Array.isArray(apiData['items']) ? apiData['items'] : [],
metadata: {
timestamp: new Date().toISOString(),
source: 'European Parliament'
}
};
}
Create src/tools/newTool.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleNewTool, newToolMetadata } from './newTool.js';
import { epClient } from '../clients/europeanParliamentClient.js';
// Mock EP client
vi.mock('../clients/europeanParliamentClient.js', () => ({
epClient: {
getNewData: vi.fn()
}
}));
describe('newTool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Input Validation', () => {
it('should accept valid parameters', async () => {
vi.mocked(epClient.getNewData).mockResolvedValue({
id: 'test-id',
name: 'Test Name',
data: [],
metadata: {
timestamp: '2024-01-01T00:00:00Z',
source: 'European Parliament'
}
});
await expect(
handleNewTool({
param1: 'value1',
param2: '2024-01-01'
})
).resolves.toBeDefined();
});
it('should reject invalid param1', async () => {
await expect(
handleNewTool({
param1: '', // Empty not allowed
param2: '2024-01-01'
})
).rejects.toThrow();
});
it('should reject invalid date format', async () => {
await expect(
handleNewTool({
param1: 'value1',
param2: '01-01-2024' // Wrong format
})
).rejects.toThrow();
});
it('should use default values', async () => {
vi.mocked(epClient.getNewData).mockResolvedValue({
id: 'test-id',
name: 'Test Name',
data: [],
metadata: {
timestamp: '2024-01-01T00:00:00Z',
source: 'European Parliament'
}
});
await handleNewTool({ param1: 'value1' });
expect(epClient.getNewData).toHaveBeenCalledWith(
expect.objectContaining({
param1: 'value1'
})
);
});
});
describe('Response Format', () => {
it('should return MCP-compliant response', async () => {
const mockData = {
id: 'test-id',
name: 'Test Name',
data: [{ item: 1 }],
metadata: {
timestamp: '2024-01-01T00:00:00Z',
source: 'European Parliament'
}
};
vi.mocked(epClient.getNewData).mockResolvedValue(mockData);
const result = await handleNewTool({ param1: 'value1' });
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
expect(result.content[0]).toHaveProperty('type', 'text');
expect(result.content[0]).toHaveProperty('text');
});
it('should return valid JSON', async () => {
const mockData = {
id: 'test-id',
name: 'Test Name',
data: [],
metadata: {
timestamp: '2024-01-01T00:00:00Z',
source: 'European Parliament'
}
};
vi.mocked(epClient.getNewData).mockResolvedValue(mockData);
const result = await handleNewTool({ param1: 'value1' });
const text = result.content[0].text;
expect(() => JSON.parse(text)).not.toThrow();
});
});
describe('Error Handling', () => {
it('should handle EP API errors gracefully', async () => {
vi.mocked(epClient.getNewData).mockRejectedValue(
new Error('EP API unavailable')
);
await expect(
handleNewTool({ param1: 'value1' })
).rejects.toThrow('Failed to execute new_tool');
});
});
describe('Tool Metadata', () => {
it('should have correct tool name', () => {
expect(newToolMetadata.name).toBe('new_tool');
});
it('should have description', () => {
expect(newToolMetadata.description).toBeDefined();
expect(newToolMetadata.description.length).toBeGreaterThan(10);
});
it('should define input schema', () => {
expect(newToolMetadata.inputSchema).toBeDefined();
expect(newToolMetadata.inputSchema.properties).toBeDefined();
});
});
});
docs/API_USAGE_GUIDE.mdREADME.md /\
/E2E\ End-to-End (10%)
/------\ - Full MCP client-server tests
/ Integr \ Integration (20%)
/----------\ - EP API client tests
/ Unit \ Unit (70%)
/--------------\ - Tool handlers, schemas, utils
Location: Colocated with source files (*.test.ts)
Coverage Target: 80% overall, 95% for security-critical code
Example:
// src/utils/cache.test.ts
import { describe, it, expect } from 'vitest';
import { LRUCache } from 'lru-cache';
describe('Cache', () => {
it('should store and retrieve values', () => {
const cache = new LRUCache({ max: 10 });
cache.set('key', 'value');
expect(cache.get('key')).toBe('value');
});
it('should respect TTL', async () => {
const cache = new LRUCache({ max: 10, ttl: 100 });
cache.set('key', 'value');
await new Promise(resolve => setTimeout(resolve, 150));
expect(cache.get('key')).toBeUndefined();
});
it('should evict oldest when max reached', () => {
const cache = new LRUCache({ max: 2 });
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
expect(cache.has('key1')).toBe(false);
expect(cache.has('key2')).toBe(true);
expect(cache.has('key3')).toBe(true);
});
});
Location: tests/integration/
Purpose: Test EP API client integration
Example:
// tests/integration/epClient.integration.test.ts
import { describe, it, expect } from 'vitest';
import { epClient } from '../../src/clients/europeanParliamentClient.js';
describe('EP API Client Integration', () => {
it('should fetch MEPs from real API', async () => {
const result = await epClient.getMEPs({
country: 'SE',
limit: 5
});
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
expect(result.data.length).toBeGreaterThan(0);
expect(result.total).toBeGreaterThan(0);
});
it('should respect rate limiting', async () => {
const requests = Array(5).fill(null).map(() =>
epClient.getMEPs({ limit: 1 })
);
const start = Date.now();
await Promise.all(requests);
const duration = Date.now() - start;
// Should complete reasonably fast with caching
expect(duration).toBeLessThan(5000);
});
});
Location: tests/e2e/
Purpose: Test complete MCP client-server flow
Example:
// tests/e2e/mepQueries.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
describe('MEP Queries E2E', () => {
let client: Client;
let transport: StdioClientTransport;
beforeAll(async () => {
transport = new StdioClientTransport({
command: 'node',
args: ['dist/index.js']
});
client = new Client({
name: 'test-client',
version: '1.0.0'
}, {
capabilities: {}
});
await client.connect(transport);
});
afterAll(async () => {
await client.close();
});
it('should list MEPs', async () => {
const result = await client.callTool('get_meps', {
country: 'SE',
limit: 10
});
expect(result.content).toBeDefined();
expect(result.content[0].type).toBe('text');
const data = JSON.parse(result.content[0].text);
expect(data.data).toBeDefined();
expect(Array.isArray(data.data)).toBe(true);
});
});
Location: tests/performance/
Purpose: Verify performance requirements (<200ms cached)
Example:
// tests/performance/benchmarks.test.ts
import { describe, it, expect } from 'vitest';
import { measureTime } from '../helpers/testUtils.js';
describe('Performance Benchmarks', () => {
it('should respond in <200ms for cached requests', async () => {
// Warm cache
await client.callTool('get_meps', { country: 'SE' });
// Measure cached response
const duration = await measureTime(() =>
client.callTool('get_meps', { country: 'SE' })
);
expect(duration).toBeLessThan(200);
});
it('should handle 50 concurrent requests', async () => {
const requests = Array(50).fill(null).map((_, i) =>
client.callTool('get_meps', {
country: 'SE',
offset: i * 10,
limit: 10
})
);
const start = Date.now();
await Promise.all(requests);
const duration = Date.now() - start;
// Should complete in reasonable time
expect(duration).toBeLessThan(10000);
});
});
1. Use Strict Mode
// tsconfig.json already enables strict mode
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
2. Explicit Return Types
// โ Bad
export async function getData(id: string) {
return await fetch(`/api/${id}`);
}
// โ
Good
export async function getData(id: string): Promise<Response> {
return await fetch(`/api/${id}`);
}
3. No any Type
// โ Bad
function process(data: any) {
return data.field;
}
// โ
Good
function process(data: unknown): string {
if (typeof data === 'object' && data !== null && 'field' in data) {
return String(data.field);
}
throw new Error('Invalid data structure');
}
4. Use Branded Types
// Good for IDs
type MEPID = string & { readonly __brand: 'MEPID' };
function getMEP(id: MEPID): Promise<MEP> {
// ...
}
// Prevents accidental string mixing
const id: MEPID = 'MEP-123' as MEPID;
getMEP(id); // OK
getMEP('random-string'); // โ Type error
| Type | Convention | Example |
|---|---|---|
| Files | camelCase | getMEPs.ts |
| Classes | PascalCase | RateLimiter |
| Interfaces | PascalCase | MEPDetails |
| Functions | camelCase | handleGetMEPs |
| Constants | UPPER_SNAKE_CASE | DEFAULT_LIMIT |
| Private fields | _camelCase | _cache |
| Types | PascalCase | ToolMetadata |
1. Import Order
// 1. External packages
import { z } from 'zod';
import { LRUCache } from 'lru-cache';
// 2. Internal modules (absolute imports)
import { epClient } from '../clients/europeanParliamentClient.js';
import { MEPSchema } from '../schemas/europeanParliament.js';
// 3. Types
import type { MEP, Committee } from '../types/europeanParliament.js';
2. File Structure
/**
* File header comment
*/
// Imports
import { ... } from '...';
// Constants
const DEFAULT_LIMIT = 50;
// Types/Interfaces
interface LocalType {
// ...
}
// Main implementation
export async function mainFunction() {
// ...
}
// Helper functions (private)
function helperFunction() {
// ...
}
// Exports
export const metadata = {
// ...
};
// Always catch and sanitize errors
try {
const result = await externalAPI();
return result;
} catch (error) {
// Log full error internally
logger.error('External API failed', { error });
// Throw sanitized error
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Operation failed: ${message}`);
}
JSDoc for Public APIs:
/**
* Retrieve Members of European Parliament with filters
*
* @param args - Tool arguments including country, group, committee filters
* @returns MCP-compliant response with paginated MEP data
* @throws {ValidationError} If input parameters are invalid
* @throws {APIError} If European Parliament API request fails
*
* @example
* ```typescript
* const result = await handleGetMEPs({
* country: 'SE',
* group: 'S&D',
* limit: 20
* });
* ```
*
* @security
* - Input validated with Zod schemas
* - Rate limited to 100 requests per 15 minutes
* - All requests logged for audit
*
* @performance
* - Cached responses: <1ms
* - API requests: ~150-200ms
* - Cache TTL: 15 minutes
*/
export async function handleGetMEPs(args: unknown): Promise<MCPResponse> {
// Implementation
}
Always use Zod schemas:
// Define schema
const InputSchema = z.object({
id: z.string().min(1).max(100),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
keywords: z.string().regex(/^[a-zA-Z0-9\s\-_]+$/)
});
// Validate input
export function handleTool(args: unknown) {
const params = InputSchema.parse(args); // Throws on invalid
// params is now type-safe
}
// Validate API responses
const OutputSchema = z.object({
id: z.string(),
name: z.string(),
data: z.array(z.unknown())
});
const apiResponse = await fetch(...);
const validated = OutputSchema.parse(apiResponse); // Ensure structure
return validated;
// โ Bad - Exposes internal details
catch (error) {
throw error;
}
// โ
Good - Sanitized error
catch (error) {
logger.error('Internal error', { error });
throw new Error('Operation failed');
}
// Always check rate limits
if (!await rateLimiter.tryRemoveTokens(1)) {
throw new Error('Rate limit exceeded');
}
// Make request
const response = await fetch(...);
// Log all data access
logger.info('Accessed MEP data', {
user: 'client-id',
mepId: params.id,
timestamp: new Date().toISOString()
});
// Use LRU cache with appropriate TTL
const cache = new LRUCache<string, any>({
max: 500, // Max entries
ttl: 15 * 60 * 1000, // 15 minutes
allowStale: false
});
// Cache key strategy
function getCacheKey(params: any): string {
return `${method}:${JSON.stringify(params)}`;
}
// Check cache before API call
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
// Fetch and cache
const fresh = await fetchFromAPI();
cache.set(cacheKey, fresh);
return fresh;
// โ Bad - Sequential
const meps = await getMEPs();
const sessions = await getSessions();
const votes = await getVotes();
// โ
Good - Parallel
const [meps, sessions, votes] = await Promise.all([
getMEPs(),
getSessions(),
getVotes()
]);
// Limit array sizes
const results = largeArray.slice(0, 100); // Take first 100
// Stream large datasets
async function* streamData() {
let offset = 0;
while (true) {
const batch = await fetchBatch(offset, 100);
if (batch.length === 0) break;
for (const item of batch) {
yield item;
}
offset += batch.length;
}
}
# Fork on GitHub, then:
git clone https://github.com/YOUR_USERNAME/European-Parliament-MCP-Server.git
cd European-Parliament-MCP-Server
git remote add upstream https://github.com/Hack23/European-Parliament-MCP-Server.git
git checkout -b feature/my-feature
Branch naming:
feature/ - New featuresfix/ - Bug fixesdocs/ - Documentationrefactor/ - Code refactoringtest/ - Test improvementsFollow code style, add tests, update documentation.
git add .
git commit -m "feat: add new MEP filtering option"
Commit message format (Conventional Commits):
type(scope): description
[optional body]
[optional footer]
Types:
feat: New featurefix: Bug fixdocs: Documentationstyle: Formattingrefactor: Code restructuringtest: Testingchore: MaintenanceExamples:
feat(tools): add committee member filtering
fix(cache): resolve cache invalidation bug
docs(api): update get_meps examples
test(e2e): add voting records integration test
git push origin feature/my-feature
Then create Pull Request on GitHub.
Automated Checks:
Code Review:
Merge:
Follow Semantic Versioning:
Create Release PR:
package.jsonCHANGELOG.mdchore(release): v1.2.0Merge to Main:
GitHub Actions:
# Update version
npm version minor # or major, patch
# Push with tags
git push origin main --follow-tags
# Create GitHub release
gh release create v1.2.0 \
--title "v1.2.0" \
--notes-file CHANGELOG.md
Built with โค๏ธ by Hack23 AB
ISMS-compliant development demonstrating security excellence