Files
mcp-paperclip/docs/superpowers/plans/2026-04-07-mcp-paperclip.md
T
ealmeida 2753360787 feat: mcp-paperclip v1.0.0 — 165 tools para Paperclip AI
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>
2026-04-07 02:56:45 +01:00

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:

  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:
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