@hapticpaper/mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/dist/auth/access.js +49 -0
- package/dist/auth/oauth.js +135 -0
- package/dist/auth/storage.js +82 -0
- package/dist/client/hireHumanClient.js +107 -0
- package/dist/index.js +309 -0
- package/dist/resources/index.js +31 -0
- package/dist/tools/account.js +86 -0
- package/dist/tools/estimates.js +123 -0
- package/dist/tools/index.js +12 -0
- package/dist/tools/qualification.js +273 -0
- package/dist/tools/tasks.js +246 -0
- package/dist/tools/workers.js +139 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @hapticpaper/mcp-server
|
|
2
|
+
|
|
3
|
+
Official MCP Server for [Haptic Paper](https://hapticpaper.com) - Connect your account to AI assistants like Claude, Gemini, and ChatGPT.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your MCP client configuration:
|
|
8
|
+
|
|
9
|
+
### Claude Desktop / Gemini
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"hapticpaper": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "@hapticpaper/mcp-server"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### First-Time Authentication
|
|
23
|
+
|
|
24
|
+
The first time you use any tool, you'll be prompted to authenticate:
|
|
25
|
+
|
|
26
|
+
1. A browser window will open to `hapticpaper.com`
|
|
27
|
+
2. Sign in or create an account
|
|
28
|
+
3. Authorize the MCP connection
|
|
29
|
+
4. Return to your AI assistant - you're connected!
|
|
30
|
+
|
|
31
|
+
Your tokens are stored securely in `~/.hapticpaper/tokens.json`.
|
|
32
|
+
|
|
33
|
+
## Available Tools
|
|
34
|
+
|
|
35
|
+
| Tool | Description |
|
|
36
|
+
|------|-------------|
|
|
37
|
+
| `create_task` | Create a new task for a human worker |
|
|
38
|
+
| `get_task` | Get details of a specific task |
|
|
39
|
+
| `list_my_tasks` | List your tasks |
|
|
40
|
+
| `cancel_task` | Cancel a pending task |
|
|
41
|
+
| `search_workers` | Find available workers |
|
|
42
|
+
| `get_estimate` | Get price/time estimates |
|
|
43
|
+
| `get_account` | View your account info |
|
|
44
|
+
|
|
45
|
+
## Manual Authentication
|
|
46
|
+
|
|
47
|
+
To authenticate manually (useful for troubleshooting):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @hapticpaper/mcp-server auth
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Environment Variables
|
|
54
|
+
|
|
55
|
+
| Variable | Description | Default |
|
|
56
|
+
|----------|-------------|---------|
|
|
57
|
+
| `AUTH_URL` | OAuth authorization endpoint | `https://hapticpaper.com/oauth/authorize` |
|
|
58
|
+
| `TOKEN_URL` | OAuth token endpoint | `https://hapticpaper.com/api/v1/oauth/token` |
|
|
59
|
+
| `API_URL` | API base URL | `https://hapticpaper.com/api/v1` |
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
function parseScopesFromClaims(claims) {
|
|
3
|
+
const scopes = [];
|
|
4
|
+
if (typeof claims?.scope === 'string' && claims.scope.trim()) {
|
|
5
|
+
scopes.push(...claims.scope
|
|
6
|
+
.split(/[\s,]+/)
|
|
7
|
+
.map((s) => s.trim())
|
|
8
|
+
.filter(Boolean));
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(claims?.permissions)) {
|
|
11
|
+
scopes.push(...claims.permissions
|
|
12
|
+
.map((s) => (typeof s === 'string' ? s.trim() : ''))
|
|
13
|
+
.filter(Boolean));
|
|
14
|
+
}
|
|
15
|
+
return Array.from(new Set(scopes));
|
|
16
|
+
}
|
|
17
|
+
export function verifyAccessToken(extra) {
|
|
18
|
+
const token = extra?.authInfo?.token;
|
|
19
|
+
if (!token) {
|
|
20
|
+
throw new Error('Authentication required: connect your account to use this tool.');
|
|
21
|
+
}
|
|
22
|
+
const secret = process.env.JWT_SECRET;
|
|
23
|
+
if (!secret) {
|
|
24
|
+
throw new Error('Server misconfigured: JWT_SECRET is not set.');
|
|
25
|
+
}
|
|
26
|
+
const decoded = jwt.verify(token, secret);
|
|
27
|
+
if (!decoded || typeof decoded !== 'object') {
|
|
28
|
+
throw new Error('Invalid authentication token.');
|
|
29
|
+
}
|
|
30
|
+
const scopes = parseScopesFromClaims(decoded);
|
|
31
|
+
return {
|
|
32
|
+
token,
|
|
33
|
+
userId: decoded.id ?? decoded.sub,
|
|
34
|
+
userType: decoded.userType,
|
|
35
|
+
clientId: decoded.client_id,
|
|
36
|
+
scopes,
|
|
37
|
+
raw: decoded,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function requireScopes(extra, required) {
|
|
41
|
+
const auth = verifyAccessToken(extra);
|
|
42
|
+
if (required.length === 0)
|
|
43
|
+
return auth;
|
|
44
|
+
const ok = required.every((s) => auth.scopes.includes(s));
|
|
45
|
+
if (!ok) {
|
|
46
|
+
throw new Error(`Missing required permission(s): ${required.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
return auth;
|
|
49
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import open from 'open';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
export class MCPOAuthHandler extends EventEmitter {
|
|
9
|
+
config;
|
|
10
|
+
codeVerifier;
|
|
11
|
+
state;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
super();
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.codeVerifier = this.generateCodeVerifier();
|
|
16
|
+
this.state = crypto.randomBytes(16).toString('hex');
|
|
17
|
+
}
|
|
18
|
+
generateCodeVerifier() {
|
|
19
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
20
|
+
}
|
|
21
|
+
generateCodeChallenge(verifier) {
|
|
22
|
+
return crypto
|
|
23
|
+
.createHash('sha256')
|
|
24
|
+
.update(verifier)
|
|
25
|
+
.digest('base64url');
|
|
26
|
+
}
|
|
27
|
+
async authenticate() {
|
|
28
|
+
const codeChallenge = this.generateCodeChallenge(this.codeVerifier);
|
|
29
|
+
// Build auth URL
|
|
30
|
+
const authUrl = new URL(this.config.authorizationUrl);
|
|
31
|
+
authUrl.searchParams.set('client_id', this.config.clientId);
|
|
32
|
+
authUrl.searchParams.set('redirect_uri', this.config.redirectUri);
|
|
33
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
34
|
+
authUrl.searchParams.set('scope', this.config.scopes.join(' '));
|
|
35
|
+
authUrl.searchParams.set('state', this.state);
|
|
36
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
37
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
38
|
+
console.error('Opening browser to:', authUrl.toString());
|
|
39
|
+
// Start server first
|
|
40
|
+
const codePromise = this.startCallbackServer();
|
|
41
|
+
// Open browser
|
|
42
|
+
await open(authUrl.toString());
|
|
43
|
+
const code = await codePromise;
|
|
44
|
+
return this.exchangeCode(code);
|
|
45
|
+
}
|
|
46
|
+
startCallbackServer() {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
// Extract port from redirect URI
|
|
49
|
+
const port = new URL(this.config.redirectUri).port || '80';
|
|
50
|
+
const server = http.createServer((req, res) => {
|
|
51
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
52
|
+
if (url.pathname !== new URL(this.config.redirectUri).pathname) {
|
|
53
|
+
res.writeHead(404);
|
|
54
|
+
res.end('Not found');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const code = url.searchParams.get('code');
|
|
58
|
+
const state = url.searchParams.get('state');
|
|
59
|
+
const error = url.searchParams.get('error');
|
|
60
|
+
if (error) {
|
|
61
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
62
|
+
res.end(`<h1>Auth Failed</h1><p>${error}</p>`);
|
|
63
|
+
server.close();
|
|
64
|
+
reject(new Error(error));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (state !== this.state) {
|
|
68
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
69
|
+
res.end(`<h1>Auth Failed</h1><p>State mismatch</p>`);
|
|
70
|
+
server.close();
|
|
71
|
+
reject(new Error('State mismatch'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
75
|
+
res.end(`<h1>Authenticated!</h1><p>You can close this window and return to Claude.</p><script>window.close()</script>`);
|
|
76
|
+
server.close();
|
|
77
|
+
resolve(code);
|
|
78
|
+
});
|
|
79
|
+
server.listen(parseInt(port), () => {
|
|
80
|
+
// console.error(`Listening on port ${port}`);
|
|
81
|
+
});
|
|
82
|
+
// Safety timeout
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
server.close();
|
|
85
|
+
// Don't reject if already resolved, but simple way:
|
|
86
|
+
// This might leak if server is closed by request.
|
|
87
|
+
// Proper handling omitted for brevity but server.close covers it mostly.
|
|
88
|
+
}, 300000); // 5 mins
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async exchangeCode(code) {
|
|
92
|
+
// Use fetch or axios
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
params.append('grant_type', 'authorization_code');
|
|
95
|
+
params.append('client_id', this.config.clientId);
|
|
96
|
+
params.append('code', code);
|
|
97
|
+
params.append('redirect_uri', this.config.redirectUri);
|
|
98
|
+
params.append('code_verifier', this.codeVerifier);
|
|
99
|
+
const response = await fetch(this.config.tokenUrl, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
102
|
+
body: params
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(`Token exchange failed: ${await response.text()}`);
|
|
106
|
+
}
|
|
107
|
+
const data = await response.json();
|
|
108
|
+
data.expires_at = Date.now() + (data.expires_in * 1000);
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export class TokenManager {
|
|
113
|
+
tokenPath;
|
|
114
|
+
constructor() {
|
|
115
|
+
this.tokenPath = path.join(os.homedir(), '.hapticpaper', 'tokens.json');
|
|
116
|
+
}
|
|
117
|
+
async saveTokens(tokens) {
|
|
118
|
+
const dir = path.dirname(this.tokenPath);
|
|
119
|
+
if (!fs.existsSync(dir)) {
|
|
120
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
await fs.promises.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2));
|
|
123
|
+
}
|
|
124
|
+
async loadTokens() {
|
|
125
|
+
try {
|
|
126
|
+
if (!fs.existsSync(this.tokenPath))
|
|
127
|
+
return null;
|
|
128
|
+
const content = await fs.promises.readFile(this.tokenPath, 'utf-8');
|
|
129
|
+
return JSON.parse(content);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
export class TokenManager {
|
|
5
|
+
tokenPath;
|
|
6
|
+
tokens = null;
|
|
7
|
+
refreshUrl;
|
|
8
|
+
clientId;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.refreshUrl = config.refreshUrl;
|
|
11
|
+
this.clientId = config.clientId;
|
|
12
|
+
this.tokenPath = path.join(os.homedir(), '.hapticpaper', 'tokens.json');
|
|
13
|
+
}
|
|
14
|
+
async getValidToken() {
|
|
15
|
+
// Load from disk if not memory
|
|
16
|
+
if (!this.tokens) {
|
|
17
|
+
this.tokens = await this.loadTokens();
|
|
18
|
+
}
|
|
19
|
+
if (!this.tokens) {
|
|
20
|
+
throw new Error('Not authenticated. Please run authentication flow.');
|
|
21
|
+
}
|
|
22
|
+
// Check expiry
|
|
23
|
+
if (this.isExpired(this.tokens)) {
|
|
24
|
+
console.error('Token expired, refreshing...');
|
|
25
|
+
this.tokens = await this.refreshToken(this.tokens.refresh_token);
|
|
26
|
+
await this.saveTokens(this.tokens);
|
|
27
|
+
}
|
|
28
|
+
return this.tokens.access_token;
|
|
29
|
+
}
|
|
30
|
+
async saveTokens(tokens) {
|
|
31
|
+
// Ensure directory exists
|
|
32
|
+
const dir = path.dirname(this.tokenPath);
|
|
33
|
+
try {
|
|
34
|
+
await fs.mkdir(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// ignore if exists
|
|
38
|
+
}
|
|
39
|
+
// Set expires_at if missing (some providers return expires_in)
|
|
40
|
+
if (!tokens.expires_at && tokens.expires_in) {
|
|
41
|
+
tokens.expires_at = Date.now() + (tokens.expires_in * 1000);
|
|
42
|
+
}
|
|
43
|
+
await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
44
|
+
this.tokens = tokens;
|
|
45
|
+
}
|
|
46
|
+
async loadTokens() {
|
|
47
|
+
try {
|
|
48
|
+
const data = await fs.readFile(this.tokenPath, 'utf-8');
|
|
49
|
+
return JSON.parse(data);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
isExpired(tokens) {
|
|
56
|
+
// Consider expired 5 minutes before actual expiry
|
|
57
|
+
if (!tokens.expires_at)
|
|
58
|
+
return false; // Assume valid if no expiry? Or strict?
|
|
59
|
+
return tokens.expires_at < Date.now() + (5 * 60 * 1000);
|
|
60
|
+
}
|
|
61
|
+
async refreshToken(refreshToken) {
|
|
62
|
+
const params = new URLSearchParams({
|
|
63
|
+
grant_type: 'refresh_token',
|
|
64
|
+
refresh_token: refreshToken,
|
|
65
|
+
client_id: this.clientId,
|
|
66
|
+
});
|
|
67
|
+
const response = await fetch(this.refreshUrl, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
70
|
+
body: params,
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error('Token refresh failed. Please re-authenticate.');
|
|
74
|
+
}
|
|
75
|
+
const tokens = await response.json();
|
|
76
|
+
// Update expiry
|
|
77
|
+
if (tokens.expires_in) {
|
|
78
|
+
tokens.expires_at = Date.now() + (tokens.expires_in * 1000);
|
|
79
|
+
}
|
|
80
|
+
return tokens;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
export class HireHumanClient {
|
|
3
|
+
client;
|
|
4
|
+
tokenProvider;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.client = axios.create({
|
|
7
|
+
baseURL: config.baseUrl,
|
|
8
|
+
headers: {
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
this.tokenProvider = config.tokenProvider;
|
|
13
|
+
// Request interceptor to add auth token
|
|
14
|
+
this.client.interceptors.request.use(async (config) => {
|
|
15
|
+
if (this.tokenProvider) {
|
|
16
|
+
const token = await this.tokenProvider();
|
|
17
|
+
if (token) {
|
|
18
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return config;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async authHeaders(accessToken) {
|
|
25
|
+
if (accessToken) {
|
|
26
|
+
return { Authorization: `Bearer ${accessToken}` };
|
|
27
|
+
}
|
|
28
|
+
if (!this.tokenProvider) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const token = await this.tokenProvider();
|
|
32
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
33
|
+
}
|
|
34
|
+
// Account Methods
|
|
35
|
+
async getAccount(accessToken) {
|
|
36
|
+
const response = await this.client.get('/gpt/account', { headers: await this.authHeaders(accessToken) });
|
|
37
|
+
return response.data;
|
|
38
|
+
}
|
|
39
|
+
// Task Methods
|
|
40
|
+
async createTask(data, accessToken) {
|
|
41
|
+
const response = await this.client.post('/gpt/tasks', data, { headers: await this.authHeaders(accessToken) });
|
|
42
|
+
return response.data;
|
|
43
|
+
}
|
|
44
|
+
async getTask(taskId, accessToken) {
|
|
45
|
+
const response = await this.client.get(`/gpt/tasks/${taskId}`, { headers: await this.authHeaders(accessToken) });
|
|
46
|
+
return response.data;
|
|
47
|
+
}
|
|
48
|
+
async listTasks(params, accessToken) {
|
|
49
|
+
const response = await this.client.get('/gpt/tasks', { params, headers: await this.authHeaders(accessToken) });
|
|
50
|
+
return response.data;
|
|
51
|
+
}
|
|
52
|
+
async cancelTask(taskId, reason, accessToken) {
|
|
53
|
+
const response = await this.client.post(`/gpt/tasks/${taskId}/cancel`, { reason }, { headers: await this.authHeaders(accessToken) });
|
|
54
|
+
return response.data;
|
|
55
|
+
}
|
|
56
|
+
// Worker Methods
|
|
57
|
+
async searchWorkers(params, accessToken) {
|
|
58
|
+
const response = await this.client.post('/gpt/workers/search', params, { headers: await this.authHeaders(accessToken) });
|
|
59
|
+
return response.data;
|
|
60
|
+
}
|
|
61
|
+
async getWorkerProfile(workerId, accessToken) {
|
|
62
|
+
// This endpoint might not exist in gptRoutes yet, assuming it maps to backend logic
|
|
63
|
+
// If not in GPT routes, we might need to add it or use a different route
|
|
64
|
+
// For now assuming it exists based on plan
|
|
65
|
+
const response = await this.client.get(`/gpt/workers/${workerId}`, { headers: await this.authHeaders(accessToken) });
|
|
66
|
+
return response.data;
|
|
67
|
+
}
|
|
68
|
+
// Estimate Methods
|
|
69
|
+
async getEstimate(params, accessToken) {
|
|
70
|
+
const response = await this.client.post('/gpt/estimate', params, { headers: await this.authHeaders(accessToken) });
|
|
71
|
+
return response.data;
|
|
72
|
+
}
|
|
73
|
+
async getSkillCategories() {
|
|
74
|
+
// Placeholder or real endpoint
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
name: "Delivery",
|
|
78
|
+
description: "Physical delivery of items",
|
|
79
|
+
examples: ["Food delivery", "Package courier"],
|
|
80
|
+
priceRange: { min: 15, max: 50 }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "General Help",
|
|
84
|
+
description: "Moving, cleaning, organizing",
|
|
85
|
+
examples: ["Help moving boxes", "Garage cleanup"],
|
|
86
|
+
priceRange: { min: 30, max: 100 }
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
// Qualification Methods
|
|
91
|
+
async discoverEarningOpportunity(params) {
|
|
92
|
+
const response = await this.client.post('/gpt/qualification/discover', params);
|
|
93
|
+
return response.data;
|
|
94
|
+
}
|
|
95
|
+
async continueQualification(sessionId, userResponse) {
|
|
96
|
+
const response = await this.client.post(`/gpt/qualification/${sessionId}/respond`, { userResponse });
|
|
97
|
+
return response.data;
|
|
98
|
+
}
|
|
99
|
+
async getQualificationStatus(sessionId) {
|
|
100
|
+
const response = await this.client.get(`/gpt/qualification/${sessionId}`);
|
|
101
|
+
return response.data;
|
|
102
|
+
}
|
|
103
|
+
async completeQualification(sessionId) {
|
|
104
|
+
const response = await this.client.post(`/gpt/qualification/${sessionId}/complete`, {});
|
|
105
|
+
return response.data;
|
|
106
|
+
}
|
|
107
|
+
}
|