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