# MCP Paperclip Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a TypeScript MCP server that exposes 159 tools for managing the Paperclip AI orchestrator (agents, issues, routines, goals, projects, approvals, costs, skills, plugins, etc.) via STDIO + StreamableHTTP + SSE transports. **Architecture:** HTTP client wraps the Paperclip REST API at `clip.descomplicar.pt/api` with Bearer auth. Tools are organised by domain module (agents, issues, etc.). Each module exports an array of `PaperclipTool` objects. Three entry points share a common `createServer()` factory. Company ID is injected automatically. **Tech Stack:** TypeScript, `@modelcontextprotocol/sdk` (^1.10.0), `zod` (^3.24.0), `dotenv`, `winston`, Node.js 22+ **Reference MCP:** Pattern extracted from `mcp-desk-crm-sql-v3` (index.ts, index-http.ts, index-sse.ts, annotations.ts, logger.ts, types/tools.ts) --- ## File Structure ``` mcp-paperclip/ ├── src/ │ ├── index.ts # Entry STDIO │ ├── index-http.ts # Entry StreamableHTTP + SSE (porta 3175) │ ├── server.ts # createServer() factory — registers all tools │ ├── client.ts # PaperclipClient — HTTP wrapper for Paperclip API │ ├── types.ts # PaperclipTool interface, ToolResponse │ ├── tools/ │ │ ├── index.ts # Re-exports allTools array │ │ ├── health.ts # 1 tool │ │ ├── company.ts # 10 tools │ │ ├── agents.ts # 22 tools │ │ ├── agent-keys.ts # 3 tools │ │ ├── heartbeat-runs.ts # 6 tools │ │ ├── issues.ts # 17 tools │ │ ├── labels.ts # 3 tools │ │ ├── attachments.ts # 4 tools │ │ ├── approvals.ts # 10 tools │ │ ├── routines.ts # 8 tools │ │ ├── goals.ts # 5 tools │ │ ├── projects.ts # 7 tools │ │ ├── costs.ts # 12 tools │ │ ├── activity.ts # 4 tools │ │ ├── skills.ts # 6 tools │ │ ├── secrets.ts # 5 tools │ │ ├── execution-workspaces.ts # 3 tools │ │ ├── adapters.ts # 2 tools │ │ ├── portability.ts # 2 tools │ │ ├── plugins.ts # 17 tools │ │ ├── plugin-bridge.ts # 4 tools │ │ ├── assets.ts # 3 tools │ │ ├── settings.ts # 4 tools │ │ └── access.ts # 7 tools │ └── utils/ │ ├── logger.ts # Winston logger (stderr, sem cores) │ └── annotations.ts # inferAnnotations() ├── tests/ │ ├── client.test.ts │ ├── health.test.ts │ ├── agents.test.ts │ └── issues.test.ts ├── scripts/ │ └── create-api-key.sh ├── .env.example ├── .gitignore ├── tsconfig.json ├── package.json ├── CLAUDE.md └── CHANGELOG.md ``` --- ## Task 1: Project Scaffolding **Files:** - Create: `package.json` - Create: `tsconfig.json` - Create: `.env.example` - Create: `.gitignore` - Create: `CLAUDE.md` - [ ] **Step 1: Initialise package.json** ```bash cd /home/ealmeida/mcp-servers/mcp-paperclip npm init -y ``` Then overwrite with: ```json { "name": "mcp-paperclip", "version": "1.0.0", "description": "MCP Server para Paperclip AI — gestao de agentes, issues, rotinas e governance", "main": "dist/index.js", "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js", "start:http": "node dist/index-http.js", "dev": "tsc --watch", "test": "NODE_OPTIONS='--experimental-vm-modules' jest", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "quality:check": "npm run lint && npm run format:check && npm run build && npm run test" }, "author": "Descomplicar", "license": "MIT" } ``` - [ ] **Step 2: Install dependencies** ```bash npm install @modelcontextprotocol/sdk@^1.10.0 zod@^3.24.0 dotenv@^16.4.7 winston@^3.17.0 npm install -D typescript@^5.3.3 @types/node@^22.0.0 @typescript-eslint/eslint-plugin@^7.18.0 @typescript-eslint/parser@^7.18.0 eslint@^8.57.1 jest@^30.0.5 ts-jest@^29.4.0 prettier@^3.6.2 ``` - [ ] **Step 3: Create tsconfig.json** ```json { "compilerOptions": { "target": "ES2022", "module": "ES2022", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "sourceMap": true, "moduleResolution": "node", "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "noUnusedLocals": false, "noUnusedParameters": false, "types": ["node", "jest"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } ``` - [ ] **Step 4: Create .env.example** ```bash # Paperclip API PAPERCLIP_API_URL=https://clip.descomplicar.pt/api PAPERCLIP_API_KEY=pcp_mcp_... PAPERCLIP_COMPANY_ID=ebe10308-efd7-453f-86ab-13e6fe84004f # HTTP/SSE transport HTTP_PORT=3175 HTTP_HOST=127.0.0.1 # Logging LOG_LEVEL=error LOG_FILE=logs/mcp-paperclip.log ``` - [ ] **Step 5: Create .gitignore** ``` node_modules/ dist/ .env logs/ *.log ``` - [ ] **Step 6: Create CLAUDE.md** ```markdown # MCP Paperclip MCP TypeScript para integrar Claude Code com Paperclip AI (clip.descomplicar.pt). ## Comandos - `npm run build` — compilar TypeScript - `npm run start` — iniciar STDIO transport - `npm run start:http` — iniciar StreamableHTTP + SSE na porta 3175 - `npm run test` — correr testes - `npm run quality:check` — lint + format + build + test ## Estrutura - `src/client.ts` — HTTP client para API Paperclip - `src/tools/*.ts` — tools organizadas por modulo (agents, issues, etc.) - `src/server.ts` — factory createServer() partilhada entre transportes - `src/index.ts` — entry STDIO - `src/index-http.ts` — entry HTTP + SSE ## Convencoes - Tools usam snake_case: `list_agents`, `create_issue` - Company ID injectado automaticamente via env var - Logs para stderr (nunca stdout em modo STDIO) - Annotations inferidas automaticamente pelo prefixo do nome ``` - [ ] **Step 7: Commit** ```bash git init git add package.json tsconfig.json .env.example .gitignore CLAUDE.md git commit -m "chore: scaffold mcp-paperclip project" ``` --- ## Task 2: Core Types and Utilities **Files:** - Create: `src/types.ts` - Create: `src/utils/logger.ts` - Create: `src/utils/annotations.ts` - [ ] **Step 1: Create src/types.ts** ```typescript export interface ToolResponse { content: Array<{ type: 'text'; text: string; }>; [key: string]: unknown; } export interface PaperclipTool { name: string; description: string; inputSchema: { type: string; properties: Record; required?: string[]; }; handler: (args: Record) => Promise; } ``` - [ ] **Step 2: Create src/utils/logger.ts** ```typescript import * as winston from 'winston'; const logLevel = process.env.LOG_LEVEL || 'error'; const logFile = process.env.LOG_FILE || 'logs/mcp-paperclip.log'; const format = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), winston.format.json(), ); const transports: winston.transport[] = [ // MCP: logs para stderr, sem cores, JSON puro new winston.transports.Console({ stderrLevels: ['error', 'warn', 'info', 'debug'], format: winston.format.json(), }), ]; if (logFile) { transports.push(new winston.transports.File({ filename: logFile, format })); } export const logger = winston.createLogger({ level: logLevel, format, transports, exitOnError: false, }); ``` - [ ] **Step 3: Create src/utils/annotations.ts** ```typescript export function inferAnnotations(toolName: string): { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean; } { const name = toolName.toLowerCase(); const isReadOnly = name.startsWith('get_') || name.startsWith('list_') || name.startsWith('search_') || name.endsWith('_summary') || name.endsWith('_overview'); const isDestructive = name.startsWith('delete_') || name.startsWith('terminate_') || name.startsWith('revoke_') || name.startsWith('cancel_'); const isIdempotent = isReadOnly || name.startsWith('update_') || name.startsWith('set_') || name.startsWith('upsert_'); return { readOnlyHint: isReadOnly, destructiveHint: isDestructive, idempotentHint: isIdempotent, openWorldHint: false, }; } ``` - [ ] **Step 4: Commit** ```bash git add src/types.ts src/utils/logger.ts src/utils/annotations.ts git commit -m "feat: add core types, logger and annotations utilities" ``` --- ## Task 3: HTTP Client **Files:** - Create: `src/client.ts` - Create: `tests/client.test.ts` - [ ] **Step 1: Write the failing test** Create `tests/client.test.ts`: ```typescript import { PaperclipClient } from '../src/client.js'; // Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch; describe('PaperclipClient', () => { let client: PaperclipClient; beforeEach(() => { process.env.PAPERCLIP_API_URL = 'https://clip.descomplicar.pt/api'; process.env.PAPERCLIP_API_KEY = 'test_key'; process.env.PAPERCLIP_COMPANY_ID = 'test-company-id'; client = new PaperclipClient(); mockFetch.mockReset(); }); test('GET request sends correct headers', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'ok' }), }); await client.get('/health'); expect(mockFetch).toHaveBeenCalledWith( 'https://clip.descomplicar.pt/api/health', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Bearer test_key', 'Content-Type': 'application/json', }), }), ); }); test('companyPath injects company ID', () => { expect(client.companyPath('/agents')).toBe( '/companies/test-company-id/agents', ); }); test('POST sends JSON body', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: '123' }), }); await client.post('/companies/test-company-id/issues', { title: 'Test', }); expect(mockFetch).toHaveBeenCalledWith( 'https://clip.descomplicar.pt/api/companies/test-company-id/issues', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'Test' }), }), ); }); test('handles 401 with clear error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ error: 'Unauthorized' }), }); await expect(client.get('/health')).rejects.toThrow( 'Sem autorização. Verificar PAPERCLIP_API_KEY.', ); }); test('handles 404 with resource info', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', json: async () => ({ error: 'Not found' }), }); await expect(client.get('/agents/abc')).rejects.toThrow( 'Recurso não encontrado: /agents/abc', ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash npx jest tests/client.test.ts --no-cache ``` Expected: FAIL — `Cannot find module '../src/client.js'` - [ ] **Step 3: Create src/client.ts** ```typescript import { logger } from './utils/logger.js'; export class PaperclipClient { private baseUrl: string; private companyId: string; private headers: Record; constructor() { this.baseUrl = process.env.PAPERCLIP_API_URL ?? 'https://clip.descomplicar.pt/api'; this.companyId = process.env.PAPERCLIP_COMPANY_ID ?? ''; if (!process.env.PAPERCLIP_API_KEY) { logger.warn('PAPERCLIP_API_KEY not set'); } this.headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.PAPERCLIP_API_KEY ?? ''}`, }; } companyPath(suffix: string): string { return `/companies/${this.companyId}${suffix}`; } private async request( method: string, path: string, body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; const options: RequestInit = { method, headers: this.headers, }; if (body !== undefined) { options.body = JSON.stringify(body); } const response = await fetch(url, options); if (!response.ok) { await this.handleError(response, path); } return response.json() as Promise; } private async handleError( response: Response, path: string, ): Promise { const status = response.status; if (status === 401 || status === 403) { throw new Error('Sem autorização. Verificar PAPERCLIP_API_KEY.'); } if (status === 404) { throw new Error(`Recurso não encontrado: ${path}`); } let detail = ''; try { const body = await response.json(); detail = JSON.stringify(body); } catch { detail = response.statusText; } if (status === 422) { throw new Error(`Erro de validação: ${detail}`); } throw new Error(`Erro API (${status}): ${detail}`); } async get(path: string): Promise { return this.request('GET', path); } async post(path: string, body?: unknown): Promise { return this.request('POST', path, body); } async patch(path: string, body?: unknown): Promise { return this.request('PATCH', path, body); } async put(path: string, body?: unknown): Promise { return this.request('PUT', path, body); } async delete(path: string): Promise { return this.request('DELETE', path); } } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash npx jest tests/client.test.ts --no-cache ``` Expected: 5 tests PASS - [ ] **Step 5: Commit** ```bash git add src/client.ts tests/client.test.ts git commit -m "feat: add PaperclipClient HTTP wrapper with error handling" ``` --- ## Task 4: Server Factory **Files:** - Create: `src/server.ts` - [ ] **Step 1: Create src/server.ts** ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ListPromptsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { PaperclipTool } from './types.js'; import { PaperclipClient } from './client.js'; import { inferAnnotations } from './utils/annotations.js'; import { logger } from './utils/logger.js'; export function createServer(allTools: PaperclipTool[]): Server { const server = new Server({ name: 'mcp-paperclip', version: '1.0.0', }); // Capabilities completas (obrigatorio MCP v2.2) (server as any)._capabilities = { tools: {}, resources: {}, prompts: {}, }; server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, annotations: inferAnnotations(tool.name), })), })); server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [], })); server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const tool = allTools.find((t) => t.name === name); if (!tool) { return { content: [{ type: 'text', text: `Tool '${name}' não encontrada` }], }; } try { return await tool.handler(args as Record); } catch (error) { logger.error(`Erro na tool ${name}:`, error); return { content: [ { type: 'text', text: `Erro na tool ${name}: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } }); return server; } ``` - [ ] **Step 2: Commit** ```bash git add src/server.ts git commit -m "feat: add createServer factory for MCP tool registration" ``` --- ## Task 5: Health and Company Tools (Sprint 1 core) **Files:** - Create: `src/tools/health.ts` - Create: `src/tools/company.ts` - Create: `src/tools/index.ts` - [ ] **Step 1: Create src/tools/health.ts** ```typescript import { PaperclipClient } from '../client.js'; import { PaperclipTool } from '../types.js'; const client = new PaperclipClient(); export const healthTools: PaperclipTool[] = [ { name: 'get_health', description: 'Verificar estado do Paperclip — retorna versao, modo e bootstrap status', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get('/health'); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, ]; ``` - [ ] **Step 2: Create src/tools/company.ts** ```typescript import { PaperclipClient } from '../client.js'; import { PaperclipTool } from '../types.js'; const client = new PaperclipClient(); export const companyTools: PaperclipTool[] = [ { name: 'get_company', description: 'Obter detalhes da empresa Paperclip', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'list_companies', description: 'Listar todas as empresas na instancia Paperclip', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get('/companies'); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'get_company_stats', description: 'Estatisticas agregadas da empresa', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get('/companies/stats'); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'update_company', description: 'Actualizar dados da empresa (nome, descricao, etc.)', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Nome da empresa' }, description: { type: 'string', description: 'Descricao da empresa' }, }, }, handler: async (args) => { const result = await client.patch(client.companyPath(''), args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'update_company_branding', description: 'Actualizar branding da empresa (cores, tema)', inputSchema: { type: 'object', properties: { primary_color: { type: 'string', description: 'Cor primaria hex' }, accent_color: { type: 'string', description: 'Cor de destaque hex' }, }, }, handler: async (args) => { const result = await client.patch( client.companyPath('/branding'), args, ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'get_org_chart', description: 'Obter organigrama da empresa — hierarquia JSON de todos os agentes', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('/org')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'get_dashboard', description: 'Dashboard agregado — metricas de agentes, issues e custos', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('/dashboard')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'list_members', description: 'Listar membros (utilizadores humanos) da empresa', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('/members')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'update_member_permissions', description: 'Actualizar permissoes de um membro da empresa', inputSchema: { type: 'object', properties: { member_id: { type: 'string', description: 'ID do membro' }, permissions: { type: 'object', description: 'Objecto de permissoes a actualizar', }, }, required: ['member_id', 'permissions'], }, handler: async (args) => { const { member_id, ...body } = args as { member_id: string; [key: string]: unknown; }; const result = await client.patch( client.companyPath(`/members/${member_id}/permissions`), body, ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, { name: 'get_sidebar_badges', description: 'Obter badges do sidebar (contadores de items pendentes)', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('/sidebar-badges')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }, }, ]; ``` - [ ] **Step 3: Create src/tools/index.ts** ```typescript import { PaperclipTool } from '../types.js'; import { healthTools } from './health.js'; import { companyTools } from './company.js'; // Sprint 1: health + company + agents (read) // Sprints seguintes adicionam modulos aqui export const allTools: PaperclipTool[] = [ ...healthTools, ...companyTools, ]; ``` - [ ] **Step 4: Commit** ```bash git add src/tools/health.ts src/tools/company.ts src/tools/index.ts git commit -m "feat: add health and company tools (11 tools)" ``` --- ## Task 6: STDIO Entry Point **Files:** - Create: `src/index.ts` - [ ] **Step 1: Create src/index.ts** ```typescript #!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import * as dotenv from 'dotenv'; import { createServer } from './server.js'; import { allTools } from './tools/index.js'; import { logger } from './utils/logger.js'; dotenv.config(); async function main() { const server = createServer(allTools); const transport = new StdioServerTransport(); await server.connect(transport); logger.info('MCP Paperclip STDIO started', { tools: allTools.length }); } main().catch((error) => { logger.error('Fatal error', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); process.exit(1); }); ``` - [ ] **Step 2: Build and verify** ```bash npm run build ``` Expected: no errors, `dist/` populated - [ ] **Step 3: Commit** ```bash git add src/index.ts git commit -m "feat: add STDIO entry point" ``` --- ## Task 7: StreamableHTTP + SSE Entry Point **Files:** - Create: `src/index-http.ts` - [ ] **Step 1: Create src/index-http.ts** ```typescript #!/usr/bin/env node import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import * as dotenv from 'dotenv'; import * as http from 'http'; import { randomUUID } from 'crypto'; import { createServer } from './server.js'; import { allTools } from './tools/index.js'; import { logger } from './utils/logger.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; dotenv.config(); const PORT = parseInt(process.env.HTTP_PORT || '3175'); const HOST = process.env.HTTP_HOST || '127.0.0.1'; // Track active sessions const httpSessions = new Map< string, { server: Server; transport: StreamableHTTPServerTransport } >(); const sseSessions = new Map(); function createMCPServer(): Server { return createServer(allTools); } const httpServer = http.createServer(async (req, res) => { // CORS res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type, mcp-session-id', ); res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } const url = new URL(req.url || '/', `http://${HOST}:${PORT}`); // Health check if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ status: 'ok', transport: 'http+sse', httpSessions: httpSessions.size, sseSessions: sseSessions.size, tools: allTools.length, }), ); return; } // StreamableHTTP endpoint if (url.pathname === '/mcp') { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId && httpSessions.has(sessionId)) { const session = httpSessions.get(sessionId)!; await session.transport.handleRequest(req, res); return; } if (req.method === 'DELETE' && sessionId) { const session = httpSessions.get(sessionId); if (session) { await session.transport.close(); httpSessions.delete(sessionId); res.writeHead(200); res.end(); } else { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found' })); } return; } if (req.method === 'POST') { const server = createMCPServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, onsessioninitialized: (newSessionId) => { httpSessions.set(newSessionId, { server, transport }); logger.info(`HTTP session: ${newSessionId}`); }, }); transport.onclose = () => { if (transport.sessionId) { httpSessions.delete(transport.sessionId); } }; await server.connect(transport); await transport.handleRequest(req, res); return; } res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } // SSE endpoint (legado) if (url.pathname === '/sse' && req.method === 'GET') { const transport = new SSEServerTransport('/message', res); const sessionId = transport.sessionId; sseSessions.set(sessionId, transport); const server = createMCPServer(); transport.onclose = () => { sseSessions.delete(sessionId); }; await server.connect(transport); return; } // SSE message endpoint if (url.pathname === '/message' && req.method === 'POST') { const sessionId = url.searchParams.get('sessionId'); if (!sessionId) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Missing sessionId' })); return; } const transport = sseSessions.get(sessionId); if (!transport) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found' })); return; } await transport.handlePostMessage(req, res); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found. Use /mcp or /sse' })); }); httpServer.listen(PORT, HOST, () => { logger.info(`MCP Paperclip HTTP+SSE started`, { host: HOST, port: PORT, tools: allTools.length, }); console.log( `MCP Paperclip running — HTTP: http://${HOST}:${PORT}/mcp | SSE: http://${HOST}:${PORT}/sse`, ); }); // Graceful shutdown const shutdown = () => { for (const [, session] of httpSessions) { session.transport.close(); } httpSessions.clear(); sseSessions.clear(); httpServer.close(() => process.exit(0)); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); ``` - [ ] **Step 2: Build and verify** ```bash npm run build ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/index-http.ts git commit -m "feat: add StreamableHTTP + SSE entry point on port 3175" ``` --- ## Task 8: Agents Tools (read + write — 22 tools) **Files:** - Create: `src/tools/agents.ts` - Create: `tests/agents.test.ts` - [ ] **Step 1: Write the failing test** Create `tests/agents.test.ts`: ```typescript import { agentTools } from '../src/tools/agents.js'; describe('agentTools', () => { test('exports 22 tools', () => { expect(agentTools).toHaveLength(22); }); test('all tools have name, description, inputSchema, handler', () => { for (const tool of agentTools) { expect(tool.name).toBeDefined(); expect(tool.description).toBeDefined(); expect(tool.inputSchema).toBeDefined(); expect(typeof tool.handler).toBe('function'); } }); test('tool names use snake_case', () => { for (const tool of agentTools) { expect(tool.name).toMatch(/^[a-z][a-z0-9_]*$/); } }); test('includes expected tools', () => { const names = agentTools.map((t) => t.name); expect(names).toContain('list_agents'); expect(names).toContain('get_agent'); expect(names).toContain('create_agent'); expect(names).toContain('wakeup_agent'); expect(names).toContain('terminate_agent'); expect(names).toContain('delete_agent'); expect(names).toContain('create_agent_hire'); expect(names).toContain('invoke_agent_heartbeat'); }); test('create_agent requires name and role', () => { const createTool = agentTools.find((t) => t.name === 'create_agent'); expect(createTool?.inputSchema.required).toContain('name'); expect(createTool?.inputSchema.required).toContain('role'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash npx jest tests/agents.test.ts --no-cache ``` Expected: FAIL - [ ] **Step 3: Create src/tools/agents.ts** ```typescript import { PaperclipClient } from '../client.js'; import { PaperclipTool } from '../types.js'; const client = new PaperclipClient(); export const agentTools: PaperclipTool[] = [ // READ { name: 'list_agents', description: 'Listar todos os agentes da empresa Paperclip', inputSchema: { type: 'object', properties: {} }, handler: async () => { const result = await client.get(client.companyPath('/agents')); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent', description: 'Obter detalhes de um agente especifico', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_runtime_state', description: 'Estado runtime do agente — idle, working, paused, etc.', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/runtime-state`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_configuration', description: 'Configuracao actual do agente — modelo, budget, instrucoes', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/configuration`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_config_revisions', description: 'Historico de revisoes da configuracao do agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/config-revisions`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'rollback_agent_config', description: 'Reverter configuracao do agente para uma revisao anterior', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, revision_id: { type: 'string', description: 'ID da revisao' }, }, required: ['agent_id', 'revision_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/config-revisions/${args.revision_id}/rollback`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_skills', description: 'Listar skills atribuidas ao agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/skills`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'sync_agent_skills', description: 'Sincronizar skills do agente com o sistema de skills da empresa', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/skills/sync`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_task_sessions', description: 'Listar sessoes de trabalho do agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/task-sessions`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'get_agent_instructions_bundle', description: 'Obter bundle de instrucoes do agente (AGENTS.md, etc.)', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.get(`/agents/${args.agent_id}/instructions-bundle`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'update_agent_instructions_bundle', description: 'Actualizar bundle de instrucoes do agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, instructions: { type: 'string', description: 'Conteudo markdown das instrucoes' }, }, required: ['agent_id', 'instructions'], }, handler: async (args) => { const { agent_id, ...body } = args as { agent_id: string; [k: string]: unknown }; const result = await client.patch(`/agents/${agent_id}/instructions-bundle`, body); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, // WRITE { name: 'create_agent', description: 'Criar novo agente na empresa Paperclip', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Nome do agente' }, role: { type: 'string', description: 'Papel: ceo, cto, engineer, analyst, devops, pm, etc.' }, title: { type: 'string', description: 'Titulo/descricao do cargo' }, reports_to: { type: 'string', description: 'UUID do agente supervisor' }, capabilities: { type: 'string', description: 'Capacidades em texto livre' }, model: { type: 'string', description: 'Modelo LLM: claude-sonnet-4-6, claude-opus-4-6, etc.' }, budget_monthly_cents: { type: 'number', description: 'Budget mensal em centimos' }, instructions_file_path: { type: 'string', description: 'Path absoluto para AGENTS.md' }, }, required: ['name', 'role'], }, handler: async (args) => { const result = await client.post(client.companyPath('/agents'), args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'create_agent_hire', description: 'Criar pedido de contratacao de agente (passa por governance/approval)', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Nome do agente' }, role: { type: 'string', description: 'Papel do agente' }, title: { type: 'string', description: 'Titulo do cargo' }, reports_to: { type: 'string', description: 'UUID do supervisor' }, }, required: ['name', 'role'], }, handler: async (args) => { const result = await client.post(client.companyPath('/agent-hires'), args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'update_agent', description: 'Actualizar dados de um agente (nome, modelo, budget, etc.)', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, name: { type: 'string' }, title: { type: 'string' }, model: { type: 'string' }, budget_monthly_cents: { type: 'number' }, capabilities: { type: 'string' }, }, required: ['agent_id'], }, handler: async (args) => { const { agent_id, ...body } = args as { agent_id: string; [k: string]: unknown }; const result = await client.patch(`/agents/${agent_id}`, body); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'update_agent_permissions', description: 'Actualizar permissoes de um agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, permissions: { type: 'object', description: 'Permissoes a actualizar' }, }, required: ['agent_id', 'permissions'], }, handler: async (args) => { const { agent_id, ...body } = args as { agent_id: string; [k: string]: unknown }; const result = await client.patch(`/agents/${agent_id}/permissions`, body); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'update_agent_instructions_path', description: 'Actualizar path do ficheiro de instrucoes do agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, instructions_file_path: { type: 'string', description: 'Path absoluto para AGENTS.md' }, }, required: ['agent_id', 'instructions_file_path'], }, handler: async (args) => { const { agent_id, ...body } = args as { agent_id: string; [k: string]: unknown }; const result = await client.patch(`/agents/${agent_id}/instructions-path`, body); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'pause_agent', description: 'Pausar um agente — deixa de receber trabalho', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/pause`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'resume_agent', description: 'Retomar um agente pausado', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/resume`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'wakeup_agent', description: 'Acordar agente — forcar heartbeat para iniciar trabalho', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' }, issue_id: { type: 'string', description: 'Acordar em contexto de issue especifica' }, message: { type: 'string', description: 'Mensagem de contexto' }, }, required: ['agent_id'], }, handler: async (args) => { const { agent_id, ...body } = args as { agent_id: string; [k: string]: unknown }; const result = await client.post(`/agents/${agent_id}/wakeup`, body); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'invoke_agent_heartbeat', description: 'Invocar heartbeat manualmente num agente', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/heartbeat/invoke`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'terminate_agent', description: 'Terminar um agente (DESTRUTIVO — para execucao em curso)', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.post(`/agents/${args.agent_id}/terminate`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, { name: 'delete_agent', description: 'Eliminar um agente permanentemente (DESTRUTIVO)', inputSchema: { type: 'object', properties: { agent_id: { type: 'string', description: 'UUID do agente' } }, required: ['agent_id'], }, handler: async (args) => { const result = await client.delete(`/agents/${args.agent_id}`); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, }, ]; ``` - [ ] **Step 4: Update src/tools/index.ts to include agents** ```typescript import { PaperclipTool } from '../types.js'; import { healthTools } from './health.js'; import { companyTools } from './company.js'; import { agentTools } from './agents.js'; export const allTools: PaperclipTool[] = [ ...healthTools, ...companyTools, ...agentTools, ]; ``` - [ ] **Step 5: Run tests** ```bash npx jest tests/agents.test.ts --no-cache ``` Expected: 5 tests PASS - [ ] **Step 6: Build** ```bash npm run build ``` Expected: no errors - [ ] **Step 7: Commit** ```bash git add src/tools/agents.ts src/tools/index.ts tests/agents.test.ts git commit -m "feat: add 22 agent tools (read + write + lifecycle)" ``` --- ## Task 9: Remaining Tool Modules (Sprint 2-5) Each remaining module follows the **exact same pattern** as agents.ts. Each module: 1. Imports `PaperclipClient` and `PaperclipTool` 2. Creates `const client = new PaperclipClient()` 3. Exports an array of `PaperclipTool` objects 4. Each tool has: `name`, `description`, `inputSchema` (with Zod-compatible JSON Schema), `handler` 5. Handler calls client.get/post/patch/put/delete and returns `{ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }` **For each module below, create the file following the agents.ts pattern, then add the import to `src/tools/index.ts`.** The tool definitions follow the SPEC.md tables exactly. Here is the mapping: | File | Export name | Tools | SPEC Section | |------|-------------|-------|--------------| | `agent-keys.ts` | `agentKeyTools` | 3 | Section 4 | | `heartbeat-runs.ts` | `heartbeatRunTools` | 6 | Section 5 | | `issues.ts` | `issueTools` | 17 | Section 6 | | `labels.ts` | `labelTools` | 3 | Section 7 | | `attachments.ts` | `attachmentTools` | 4 | Section 8 | | `approvals.ts` | `approvalTools` | 10 | Section 9 | | `routines.ts` | `routineTools` | 8 | Section 10 | | `goals.ts` | `goalTools` | 5 | Section 11 | | `projects.ts` | `projectTools` | 7 | Section 12 | | `costs.ts` | `costTools` | 12 | Section 13 | | `activity.ts` | `activityTools` | 4 | Section 14 | | `skills.ts` | `skillTools` | 6 | Section 15 | | `secrets.ts` | `secretTools` | 5 | Section 16 | | `execution-workspaces.ts` | `executionWorkspaceTools` | 3 | Section 17 | | `adapters.ts` | `adapterTools` | 2 | Section 18 | | `portability.ts` | `portabilityTools` | 2 | Section 19 | | `plugins.ts` | `pluginTools` | 17 | Section 20 | | `plugin-bridge.ts` | `pluginBridgeTools` | 4 | Section 24 | | `assets.ts` | `assetTools` | 3 | Section 21 | | `settings.ts` | `settingsTools` | 4 | Section 22 | | `access.ts` | `accessTools` | 7 | Section 23 | **Implementation order (by sprint):** ### Sprint 2 batch (commit after each file): - [ ] `src/tools/issues.ts` — 17 tools - [ ] `src/tools/labels.ts` — 3 tools - [ ] `src/tools/attachments.ts` — 4 tools - [ ] `src/tools/agent-keys.ts` — 3 tools ### Sprint 3 batch: - [ ] `src/tools/routines.ts` — 8 tools - [ ] `src/tools/goals.ts` — 5 tools - [ ] `src/tools/projects.ts` — 7 tools - [ ] `src/tools/approvals.ts` — 10 tools ### Sprint 4 batch: - [ ] `src/tools/costs.ts` — 12 tools - [ ] `src/tools/activity.ts` — 4 tools - [ ] `src/tools/skills.ts` — 6 tools - [ ] `src/tools/secrets.ts` — 5 tools - [ ] `src/tools/execution-workspaces.ts` — 3 tools - [ ] `src/tools/adapters.ts` — 2 tools - [ ] `src/tools/portability.ts` — 2 tools - [ ] `src/tools/heartbeat-runs.ts` — 6 tools ### Sprint 5 batch: - [ ] `src/tools/plugins.ts` — 17 tools - [ ] `src/tools/plugin-bridge.ts` — 4 tools - [ ] `src/tools/assets.ts` — 3 tools - [ ] `src/tools/settings.ts` — 4 tools - [ ] `src/tools/access.ts` — 7 tools After each batch, update `src/tools/index.ts` to import and spread the new tools. - [ ] **Final index.ts should look like:** ```typescript import { PaperclipTool } from '../types.js'; import { healthTools } from './health.js'; import { companyTools } from './company.js'; import { agentTools } from './agents.js'; import { agentKeyTools } from './agent-keys.js'; import { heartbeatRunTools } from './heartbeat-runs.js'; import { issueTools } from './issues.js'; import { labelTools } from './labels.js'; import { attachmentTools } from './attachments.js'; import { approvalTools } from './approvals.js'; import { routineTools } from './routines.js'; import { goalTools } from './goals.js'; import { projectTools } from './projects.js'; import { costTools } from './costs.js'; import { activityTools } from './activity.js'; import { skillTools } from './skills.js'; import { secretTools } from './secrets.js'; import { executionWorkspaceTools } from './execution-workspaces.js'; import { adapterTools } from './adapters.js'; import { portabilityTools } from './portability.js'; import { pluginTools } from './plugins.js'; import { pluginBridgeTools } from './plugin-bridge.js'; import { assetTools } from './assets.js'; import { settingsTools } from './settings.js'; import { accessTools } from './access.js'; export const allTools: PaperclipTool[] = [ ...healthTools, ...companyTools, ...agentTools, ...agentKeyTools, ...heartbeatRunTools, ...issueTools, ...labelTools, ...attachmentTools, ...approvalTools, ...routineTools, ...goalTools, ...projectTools, ...costTools, ...activityTools, ...skillTools, ...secretTools, ...executionWorkspaceTools, ...adapterTools, ...portabilityTools, ...pluginTools, ...pluginBridgeTools, ...assetTools, ...settingsTools, ...accessTools, ]; ``` --- ## Task 10: API Key Script **Files:** - Create: `scripts/create-api-key.sh` - [ ] **Step 1: Create scripts/create-api-key.sh** ```bash #!/bin/bash # Gerar e registar Board API Key para MCP Paperclip set -euo pipefail KEY=$(node -e "console.log('pcp_mcp_' + require('crypto').randomBytes(24).toString('hex'))") HASH=$(node -e "const c=require('crypto'); console.log(c.createHash('sha256').update(process.argv[1]).digest('hex'))" "$KEY") echo "=== MCP Paperclip API Key ===" echo "PAPERCLIP_API_KEY=$KEY" echo "" echo "Registar na BD:" echo "PGPASSWORD=paperclip psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c \\" echo " \"INSERT INTO board_api_keys (user_id, name, key_hash) VALUES ('v1N5OccPn9DGq6iog7qW9nEvnXYFT3iO', 'claude-code-mcp', '$HASH');\"" ``` - [ ] **Step 2: Make executable and commit** ```bash chmod +x scripts/create-api-key.sh git add scripts/create-api-key.sh git commit -m "feat: add API key creation script" ``` --- ## Task 11: Build, Test, Verify - [ ] **Step 1: Full build** ```bash npm run build ``` Expected: 0 errors - [ ] **Step 2: Run all tests** ```bash npm test ``` Expected: all pass - [ ] **Step 3: Verify tool count** ```bash node -e " import('./dist/tools/index.js').then(m => { console.log('Total tools:', m.allTools.length); console.log('Expected: 159'); console.log('Match:', m.allTools.length === 159); }); " ``` Expected: `Match: true` - [ ] **Step 4: Smoke test STDIO** Create `.env` from `.env.example` with real values, then: ```bash echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js ``` Expected: JSON response with 159 tools listed - [ ] **Step 5: Final commit** ```bash git add -A git commit -m "feat: mcp-paperclip v1.0.0 — 159 tools, triple transport" ``` --- ## Task 12: Gateway Deploy (Sprint 6) - [ ] **Step 1: Create Gitea repo** ```bash cd /home/ealmeida/mcp-servers/mcp-paperclip git remote add origin https://gitea.descomplicar.pt/descomplicar/mcp-paperclip.git git push -u origin main ``` - [ ] **Step 2: Deploy to gateway via SSH** ```bash ssh gateway 'cd /opt/mcp-servers && git clone https://gitea.descomplicar.pt/descomplicar/mcp-paperclip.git && cd mcp-paperclip && npm install && npm run build' ``` - [ ] **Step 3: PM2 setup on gateway** ```bash ssh gateway 'cd /opt/mcp-servers/mcp-paperclip && pm2 start dist/index-http.js --name mcp-paperclip -- && pm2 save' ``` - [ ] **Step 4: Register port in port-map.json** Add entry: `"mcp-paperclip": 3175` - [ ] **Step 5: Update ~/.claude.json locally** Add the paperclip MCP server config as per SPEC.md. - [ ] **Step 6: Update mcps.json** Add mcp-paperclip entry to `~/.claude/_resources/mcps.json` - [ ] **Step 7: Verify gateway health** ```bash curl https://gateway.descomplicar.pt:3175/health ``` Expected: `{"status":"ok","transport":"http+sse","tools":159}` - [ ] **Step 8: Final commit with CI** ```bash git add -A git commit -m "chore: gateway deploy config and documentation" git push ```