Triple transport (STDIO + StreamableHTTP + SSE porta 3175). 24 modulos: agents, issues, approvals, routines, goals, projects, costs, activity, skills, secrets, plugins, assets, settings, access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
51 KiB
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
cd /home/ealmeida/mcp-servers/mcp-paperclip
npm init -y
Then overwrite with:
{
"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
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
{
"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
# 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
# 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
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
export interface ToolResponse {
content: Array<{
type: 'text';
text: string;
}>;
[key: string]: unknown;
}
export interface PaperclipTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, unknown>;
required?: string[];
};
handler: (args: Record<string, unknown>) => Promise<ToolResponse>;
}
- Step 2: Create src/utils/logger.ts
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
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
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:
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
npx jest tests/client.test.ts --no-cache
Expected: FAIL — Cannot find module '../src/client.js'
- Step 3: Create src/client.ts
import { logger } from './utils/logger.js';
export class PaperclipClient {
private baseUrl: string;
private companyId: string;
private headers: Record<string, string>;
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<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
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<T>;
}
private async handleError(
response: Response,
path: string,
): Promise<never> {
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<T>(path: string): Promise<T> {
return this.request<T>('GET', path);
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, body);
}
async patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PATCH', path, body);
}
async put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PUT', path, body);
}
async delete<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path);
}
}
- Step 4: Run tests to verify they pass
npx jest tests/client.test.ts --no-cache
Expected: 5 tests PASS
- Step 5: Commit
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
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<string, unknown>);
} 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
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
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
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
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
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
#!/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
npm run build
Expected: no errors, dist/ populated
- Step 3: Commit
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
#!/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<string, SSEServerTransport>();
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
npm run build
Expected: no errors
- Step 3: Commit
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:
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
npx jest tests/agents.test.ts --no-cache
Expected: FAIL
- Step 3: Create src/tools/agents.ts
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
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
npx jest tests/agents.test.ts --no-cache
Expected: 5 tests PASS
- Step 6: Build
npm run build
Expected: no errors
- Step 7: Commit
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:
- Imports
PaperclipClientandPaperclipTool - Creates
const client = new PaperclipClient() - Exports an array of
PaperclipToolobjects - Each tool has:
name,description,inputSchema(with Zod-compatible JSON Schema),handler - 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 toolssrc/tools/labels.ts— 3 toolssrc/tools/attachments.ts— 4 toolssrc/tools/agent-keys.ts— 3 tools
Sprint 3 batch:
src/tools/routines.ts— 8 toolssrc/tools/goals.ts— 5 toolssrc/tools/projects.ts— 7 toolssrc/tools/approvals.ts— 10 tools
Sprint 4 batch:
src/tools/costs.ts— 12 toolssrc/tools/activity.ts— 4 toolssrc/tools/skills.ts— 6 toolssrc/tools/secrets.ts— 5 toolssrc/tools/execution-workspaces.ts— 3 toolssrc/tools/adapters.ts— 2 toolssrc/tools/portability.ts— 2 toolssrc/tools/heartbeat-runs.ts— 6 tools
Sprint 5 batch:
src/tools/plugins.ts— 17 toolssrc/tools/plugin-bridge.ts— 4 toolssrc/tools/assets.ts— 3 toolssrc/tools/settings.ts— 4 toolssrc/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:
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
#!/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
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
npm run build
Expected: 0 errors
- Step 2: Run all tests
npm test
Expected: all pass
- Step 3: Verify tool count
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:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
Expected: JSON response with 159 tools listed
- Step 5: Final commit
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
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
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
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
curl https://gateway.descomplicar.pt:3175/health
Expected: {"status":"ok","transport":"http+sse","tools":159}
- Step 8: Final commit with CI
git add -A
git commit -m "chore: gateway deploy config and documentation"
git push