chore: initial commit with Phase 0 setup
This commit is contained in:
72
packages/mcp-server/README.md
Normal file
72
packages/mcp-server/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Agent Bounty Monetization MCP Server
|
||||
|
||||
This package implements the Model Context Protocol (MCP) server for the Agent Bounty Protocol. It exposes tools that allow AI agents to interact with the VibeWork/Agent Bounty platform, such as fetching open tasks, claiming them, and submitting solutions.
|
||||
|
||||
## Local Testing & Development
|
||||
|
||||
You can test this MCP server locally by running it in `stdio` mode and connecting it to compatible clients like **Claude Desktop** or **Cursor**.
|
||||
|
||||
### 1. Build the Server
|
||||
|
||||
Before testing, ensure the monorepo dependencies are installed and the server is built:
|
||||
|
||||
```bash
|
||||
# In the root of the monorepo
|
||||
pnpm install
|
||||
pnpm -r build
|
||||
```
|
||||
|
||||
The compiled output will be generated in `dist/index.js`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Connect to Claude Desktop
|
||||
|
||||
To add this MCP server to Claude Desktop, you need to modify your Claude config file.
|
||||
|
||||
1. Open your Claude Desktop configuration file:
|
||||
- **Mac**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
2. Add the following entry to the `mcpServers` object, replacing `<ABSOLUTE_PATH_TO_MONOREPO>` with the absolute path to your `agent-bounty-protocol` folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-bounty-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"<ABSOLUTE_PATH_TO_MONOREPO>/packages/mcp-server/dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Restart Claude Desktop completely. The tools (`list_open_tasks`, `claim_task`, etc.) should now be available for Claude to use.
|
||||
|
||||
---
|
||||
|
||||
### 3. Connect to Cursor
|
||||
|
||||
To test inside Cursor's Composer or Chat:
|
||||
|
||||
1. Open Cursor Settings.
|
||||
2. Navigate to **Features** -> **MCP**.
|
||||
3. Click **+ Add new MCP server**.
|
||||
4. Configure as follows:
|
||||
- **Type**: `command`
|
||||
- **Name**: `agent-bounty-mcp`
|
||||
- **Command**: `node <ABSOLUTE_PATH_TO_MONOREPO>/packages/mcp-server/dist/index.js`
|
||||
*(Make sure to use the absolute path to `dist/index.js`)*
|
||||
5. Save and refresh. Cursor should now detect the tools.
|
||||
|
||||
### Development Mode (Watch)
|
||||
|
||||
If you are actively developing the server and want it to recompile automatically, you can run:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will run TypeScript in watch mode. Note that Claude Desktop/Cursor will still need to be refreshed or restarted to pick up structural changes to the tools.
|
||||
29
packages/mcp-server/package.json
Normal file
29
packages/mcp-server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@agent-bounty/mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent Bounty Monetization MCP Server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"agent-bounty-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"dev": "tsc --project tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"start": "node ./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-bounty/contracts": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
158
packages/mcp-server/src/index.ts
Normal file
158
packages/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from "express";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
MCPToolName,
|
||||
ListOpenTasksRequestSchema,
|
||||
ClaimTaskRequestSchema,
|
||||
SubmitSolutionRequestSchema,
|
||||
} from "@agent-bounty/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "agent-bounty-mcp",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
import "dotenv/config";
|
||||
import { proxyToBackend } from "./proxy.js";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL;
|
||||
const API_KEY = process.env.API_KEY;
|
||||
|
||||
if (!API_BASE_URL || !API_KEY) {
|
||||
console.error("Warning: API_BASE_URL or API_KEY is not set. The MCP server will fail to proxy requests.");
|
||||
}
|
||||
|
||||
// The proxyToBackend function has been extracted to proxy.js
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 工具註冊
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: MCPToolName.LIST_OPEN_TASKS,
|
||||
description: "List available tasks on Agent Bounty based on your skills. Fetches open bounties with their reward amount and acceptance criteria.",
|
||||
inputSchema: zodToJsonSchema(ListOpenTasksRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.CLAIM_TASK,
|
||||
description: "Claim a specific task to start working on it. This places a financial Auth-Hold lock on the task. You must finish it within 1 hour.",
|
||||
inputSchema: zodToJsonSchema(ClaimTaskRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.SUBMIT_SOLUTION,
|
||||
description: "Submit your code solution for an executing task. The solution will be tested in the sandbox environment. You must provide all requested files.",
|
||||
inputSchema: zodToJsonSchema(SubmitSolutionRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.CHECK_PAYOUT_STATUS,
|
||||
description: "Check the payout and settlement status of a completed task. Returns tracking information from the reconciliation ledger.",
|
||||
inputSchema: zodToJsonSchema(
|
||||
z.object({
|
||||
task_id: z.string().uuid("Invalid task_id"),
|
||||
developer_wallet: z.string().optional(),
|
||||
}) as any
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 工具路由與後端呼叫(MVP 階段先回傳 Mock 結構)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case MCPToolName.LIST_OPEN_TASKS: {
|
||||
const parsed = ListOpenTasksRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/list_open_tasks", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.CLAIM_TASK: {
|
||||
const parsed = ClaimTaskRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/claim_task", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.SUBMIT_SOLUTION: {
|
||||
const parsed = SubmitSolutionRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/submit_solution", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.CHECK_PAYOUT_STATUS: {
|
||||
const parsed = z.object({
|
||||
task_id: z.string().uuid("Invalid task_id"),
|
||||
developer_wallet: z.string().optional(),
|
||||
}).parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/check_payout_status", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const zodErr = error as any;
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments: ${zodErr.errors.map((e: any) => `${e.path.join(".")}: ${e.message}`).join(", ")}`
|
||||
);
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new McpError(ErrorCode.InternalError, msg);
|
||||
}
|
||||
});
|
||||
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
const app = express();
|
||||
|
||||
let transport: SSEServerTransport;
|
||||
|
||||
app.get("/sse", async (req, res) => {
|
||||
transport = new SSEServerTransport("/message", res as any);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post("/message", async (req, res) => {
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req as any, res as any);
|
||||
} else {
|
||||
res.status(500).send("Transport not initialized");
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Agent Bounty Monetization MCP Server running on SSE at http://localhost:${PORT}`);
|
||||
});
|
||||
26
packages/mcp-server/src/proxy.ts
Normal file
26
packages/mcp-server/src/proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export async function proxyToBackend(
|
||||
endpoint: string,
|
||||
payload: any,
|
||||
baseUrl?: string,
|
||||
apiKey?: string
|
||||
) {
|
||||
if (!baseUrl) throw new Error("API_BASE_URL is not configured.");
|
||||
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey || ""}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Backend error (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
52
packages/mcp-server/tests/proxy.test.ts
Normal file
52
packages/mcp-server/tests/proxy.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { proxyToBackend } from "../src/proxy";
|
||||
|
||||
describe("proxyToBackend", () => {
|
||||
const MOCK_BASE_URL = "http://localhost:3000";
|
||||
const MOCK_API_KEY = "test-key";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should make a POST request with correct headers and body", async () => {
|
||||
const mockResponse = { success: true, data: "test" };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const payload = { test: 123 };
|
||||
const result = await proxyToBackend("/api/test", payload, MOCK_BASE_URL, MOCK_API_KEY);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("http://localhost:3000/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test-key",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should throw an error if API_BASE_URL is not provided", async () => {
|
||||
await expect(proxyToBackend("/api/test", {}, "", MOCK_API_KEY)).rejects.toThrow(
|
||||
"API_BASE_URL is not configured."
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if response is not ok", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "Internal Server Error",
|
||||
});
|
||||
|
||||
await expect(
|
||||
proxyToBackend("/api/test", {}, MOCK_BASE_URL, MOCK_API_KEY)
|
||||
).rejects.toThrow("Backend error (500): Internal Server Error");
|
||||
});
|
||||
});
|
||||
15
packages/mcp-server/tsconfig.json
Normal file
15
packages/mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user