@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/dist/index.js ADDED
@@ -0,0 +1,309 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
+ import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
7
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
8
+ import dotenv from 'dotenv';
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import jwt from 'jsonwebtoken';
13
+ import { HireHumanClient } from "./client/hireHumanClient.js";
14
+ import { registerAllTools } from "./tools/index.js";
15
+ import { registerAllResources } from "./resources/index.js";
16
+ import { TokenManager, MCPOAuthHandler } from "./auth/oauth.js";
17
+ dotenv.config();
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+ const command = args[0];
21
+ const tokenManager = new TokenManager();
22
+ if (command === 'auth') {
23
+ // Run Auth Flow
24
+ console.error('Starting Authentication Flow...');
25
+ const auth = new MCPOAuthHandler({
26
+ clientId: 'mcp-client', // Matches seedMcpClient.ts
27
+ authorizationUrl: process.env.AUTH_URL || 'https://hapticpaper.com/oauth/authorize',
28
+ tokenUrl: process.env.TOKEN_URL || 'https://hapticpaper.com/api/v1/oauth/token',
29
+ redirectUri: 'http://localhost:8765/callback',
30
+ scopes: ['tasks:read', 'tasks:write', 'workers:read']
31
+ });
32
+ try {
33
+ const tokens = await auth.authenticate();
34
+ await tokenManager.saveTokens(tokens);
35
+ console.error('Authentication successful! Token saved.');
36
+ process.exit(0);
37
+ }
38
+ catch (error) {
39
+ console.error('Authentication failed:', error);
40
+ process.exit(1);
41
+ }
42
+ }
43
+ function registerWidgetTemplate(server) {
44
+ // The widget bundle is built by packages/mcp-server/web and inlined into the HTML template.
45
+ const widgetBundlePath = path.resolve(process.cwd(), 'web', 'dist', 'widget.js');
46
+ if (!fs.existsSync(widgetBundlePath)) {
47
+ console.error(`[MCP] Widget bundle not found at ${widgetBundlePath}. Run: (cd packages/mcp-server/web && npm run build)`);
48
+ return;
49
+ }
50
+ const widgetJs = fs.readFileSync(widgetBundlePath, 'utf8');
51
+ server.registerResource('hirehuman-widget', 'ui://widget/hirehuman.html', {}, async () => ({
52
+ contents: [
53
+ {
54
+ uri: 'ui://widget/hirehuman.html',
55
+ mimeType: 'text/html+skybridge',
56
+ text: `
57
+ <div id="hirehuman-root"></div>
58
+ <style>
59
+ :root { color-scheme: light dark; }
60
+ body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
61
+ #hirehuman-root { padding: 12px; }
62
+ .hh-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
63
+ .hh-muted { opacity: 0.7; font-size: 12px; }
64
+ .hh-card { border: 1px solid rgba(127,127,127,0.25); border-radius: 12px; padding: 10px; margin: 8px 0; }
65
+ .hh-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
66
+ .hh-btn { border: 1px solid rgba(127,127,127,0.35); border-radius: 10px; padding: 6px 10px; background: transparent; cursor: pointer; }
67
+ .hh-btn:disabled { opacity: 0.6; cursor: not-allowed; }
68
+ .hh-pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(127,127,127,0.35); }
69
+ .hh-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
70
+ </style>
71
+ <script type="module">
72
+ ${widgetJs}
73
+ </script>
74
+ `.trim(),
75
+ _meta: {
76
+ 'openai/widgetPrefersBorder': true,
77
+ 'openai/widgetDomain': 'https://chatgpt.com',
78
+ 'openai/widgetDescription': 'Shows interactive task and worker results for Hire-a-Human tools.',
79
+ 'openai/widgetCSP': {
80
+ connect_domains: ['https://chatgpt.com'],
81
+ resource_domains: ['https://*.oaistatic.com'],
82
+ },
83
+ },
84
+ },
85
+ ],
86
+ }));
87
+ }
88
+ function createMcpServer(client) {
89
+ const server = new McpServer({
90
+ name: "hire-a-human",
91
+ version: "1.0.0"
92
+ });
93
+ registerWidgetTemplate(server);
94
+ registerAllTools(server, client);
95
+ registerAllResources(server, client);
96
+ return server;
97
+ }
98
+ // Initialize Client
99
+ const client = new HireHumanClient({
100
+ baseUrl: process.env.API_URL || 'https://hapticpaper.com/api/v1',
101
+ tokenProvider: async () => {
102
+ const tokens = await tokenManager.loadTokens();
103
+ return tokens?.access_token || '';
104
+ }
105
+ });
106
+ // Transport
107
+ const transportType = process.env.MCP_TRANSPORT || 'stdio';
108
+ if (transportType === 'http') {
109
+ const host = process.env.HOST || '127.0.0.1';
110
+ const port = Number(process.env.PORT || 3001);
111
+ const app = createMcpExpressApp({
112
+ host,
113
+ allowedHosts: process.env.MCP_ALLOWED_HOSTS?.split(',').map((s) => s.trim()).filter(Boolean),
114
+ });
115
+ // Use standard Express API to enable trust proxy for Cloud Run
116
+ app.set('trust proxy', 1);
117
+ const configuredResourceUrl = process.env.RESOURCE_SERVER_URL;
118
+ const resourceServerUrl = new URL(configuredResourceUrl || `http://${host}:${port}/mcp`);
119
+ // MCP spec: authorization base URL is the MCP server origin (path stripped)
120
+ // e.g., https://mcp.hapticpaper.com for MCP server at https://mcp.hapticpaper.com/mcp
121
+ const mcpOrigin = resourceServerUrl.origin;
122
+ // Backend OAuth endpoints
123
+ const backendBaseUrl = process.env.BACKEND_PUBLIC_URL || process.env.API_URL?.replace(/\/api\/v1\/?$/, '') || 'http://localhost:3000';
124
+ const backendOAuthBase = `${backendBaseUrl}/api/v1/oauth`;
125
+ const scopesSupported = ['tasks:read', 'tasks:write', 'workers:read'];
126
+ // RFC 8414: OAuth Authorization Server Metadata
127
+ // MCP spec says this MUST be at {authorization_base_url}/.well-known/oauth-authorization-server
128
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
129
+ res.json({
130
+ issuer: mcpOrigin,
131
+ authorization_endpoint: `${backendOAuthBase}/authorize`,
132
+ token_endpoint: `${backendOAuthBase}/token`,
133
+ registration_endpoint: `${mcpOrigin}/register`,
134
+ response_types_supported: ['code'],
135
+ grant_types_supported: ['authorization_code', 'refresh_token'],
136
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
137
+ code_challenge_methods_supported: ['S256', 'plain'],
138
+ scopes_supported: scopesSupported,
139
+ });
140
+ });
141
+ // RFC 7591: Dynamic Client Registration
142
+ // Proxy to backend
143
+ app.post('/register', async (req, res) => {
144
+ try {
145
+ const response = await fetch(`${backendOAuthBase}/register`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify(req.body),
149
+ });
150
+ const data = await response.json();
151
+ res.status(response.status).json(data);
152
+ }
153
+ catch (error) {
154
+ console.error('[MCP] Registration proxy error:', error);
155
+ res.status(500).json({ error: 'Registration failed' });
156
+ }
157
+ });
158
+ // For legacy/SDK compatibility
159
+ const issuer = mcpOrigin;
160
+ const oauthMetadata = {
161
+ issuer,
162
+ authorization_endpoint: `${backendOAuthBase}/authorize`,
163
+ token_endpoint: `${backendOAuthBase}/token`,
164
+ registration_endpoint: `${mcpOrigin}/register`,
165
+ response_types_supported: ['code'],
166
+ grant_types_supported: ['authorization_code', 'refresh_token'],
167
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
168
+ };
169
+ const authMetadataDisabled = String(process.env.MCP_AUTH_METADATA_DISABLED || '').toLowerCase() === 'true';
170
+ const canAdvertiseAuthMetadata = issuer.startsWith('https://') && resourceServerUrl.protocol === 'https:';
171
+ const shouldAdvertiseAuthMetadata = !authMetadataDisabled && canAdvertiseAuthMetadata;
172
+ // Always serve protected resource metadata at the expected URL so MCP clients
173
+ // (including VS Code and ChatGPT) can start the OAuth handshake.
174
+ const protectedResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
175
+ const protectedResourceMetadataPath = new URL(protectedResourceMetadataUrl).pathname;
176
+ app.get(protectedResourceMetadataPath, (_req, res) => {
177
+ res.json({
178
+ resource: resourceServerUrl.toString(),
179
+ authorization_servers: [issuer],
180
+ scopes_supported: scopesSupported,
181
+ resource_name: 'Hire-a-Human MCP',
182
+ });
183
+ });
184
+ if (shouldAdvertiseAuthMetadata) {
185
+ // Advertise MCP auth metadata (RFC 9728 protected-resource metadata, etc.)
186
+ app.use(mcpAuthMetadataRouter({
187
+ oauthMetadata,
188
+ resourceServerUrl,
189
+ scopesSupported,
190
+ resourceName: 'Hire-a-Human MCP',
191
+ }));
192
+ }
193
+ else if (!authMetadataDisabled) {
194
+ console.error(`[MCP] Auth metadata not advertised because URLs are not HTTPS. ` +
195
+ `Set RESOURCE_SERVER_URL and AUTHORIZATION_SERVER_ISSUER to https://... (recommended for ChatGPT Connectors), ` +
196
+ `or set MCP_AUTH_METADATA_DISABLED=true for local dev. ` +
197
+ `(issuer=${issuer}, resourceServerUrl=${resourceServerUrl.toString()})`);
198
+ }
199
+ const tokenVerifier = {
200
+ verifyAccessToken: async (token) => {
201
+ const secret = process.env.JWT_SECRET;
202
+ if (!secret) {
203
+ throw new Error('Server misconfigured: JWT_SECRET is not set');
204
+ }
205
+ const decoded = jwt.verify(token, secret);
206
+ if (!decoded || typeof decoded !== 'object') {
207
+ throw new Error('Invalid token');
208
+ }
209
+ const scopeStr = typeof decoded.scope === 'string' ? decoded.scope : '';
210
+ const permissions = Array.isArray(decoded.permissions) ? decoded.permissions : [];
211
+ const scopes = [
212
+ ...scopeStr.split(/\s+/).map((s) => s.trim()).filter(Boolean),
213
+ ...permissions.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean),
214
+ ];
215
+ const exp = decoded.exp;
216
+ return {
217
+ token,
218
+ clientId: decoded.client_id || 'unknown',
219
+ scopes: Array.from(new Set(scopes)),
220
+ expiresAt: typeof exp === 'number' ? exp : Math.floor(Date.now() / 1000) + 3600,
221
+ };
222
+ },
223
+ };
224
+ const authMiddleware = requireBearerAuth({
225
+ verifier: tokenVerifier,
226
+ requiredScopes: [],
227
+ resourceMetadataUrl: protectedResourceMetadataUrl,
228
+ });
229
+ const transports = {};
230
+ const shouldRequireAuth = (req) => {
231
+ // Only POST requests perform JSON-RPC actions. GET/DELETE are session transport mechanics.
232
+ if (req.method !== 'POST')
233
+ return false;
234
+ // Allow unauthenticated initialize + listing so clients can discover tools/resources.
235
+ const body = req.body;
236
+ if (isInitializeRequest(body))
237
+ return false;
238
+ const method = body?.method;
239
+ if (method === 'tools/list' || method === 'resources/list' || method === 'prompts/list')
240
+ return false;
241
+ // Require OAuth for tool calls and everything else.
242
+ return true;
243
+ };
244
+ app.use('/mcp', (req, res, next) => {
245
+ if (!shouldRequireAuth(req))
246
+ return next();
247
+ return authMiddleware(req, res, next);
248
+ });
249
+ const handleMcpRequest = async (req, res) => {
250
+ const sessionId = req.headers['mcp-session-id'];
251
+ let transport;
252
+ if (sessionId && transports[sessionId]) {
253
+ transport = transports[sessionId];
254
+ }
255
+ else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
256
+ transport = new StreamableHTTPServerTransport({
257
+ sessionIdGenerator: () => crypto.randomUUID(),
258
+ onsessioninitialized: (newSessionId) => {
259
+ transports[newSessionId] = transport;
260
+ },
261
+ });
262
+ transport.onclose = () => {
263
+ if (transport?.sessionId) {
264
+ delete transports[transport.sessionId];
265
+ }
266
+ };
267
+ const server = createMcpServer(client);
268
+ await server.connect(transport);
269
+ }
270
+ else {
271
+ res.status(400).json({
272
+ jsonrpc: '2.0',
273
+ error: {
274
+ code: -32000,
275
+ message: 'Bad Request: No valid session ID provided',
276
+ },
277
+ id: null,
278
+ });
279
+ return;
280
+ }
281
+ await transport.handleRequest(req, res, req.body);
282
+ };
283
+ const handleSessionRequest = async (req, res) => {
284
+ const sessionId = req.headers['mcp-session-id'];
285
+ if (!sessionId || !transports[sessionId]) {
286
+ res.status(400).send('Invalid or missing session ID');
287
+ return;
288
+ }
289
+ await transports[sessionId].handleRequest(req, res);
290
+ };
291
+ app.post('/mcp', handleMcpRequest);
292
+ app.get('/mcp', handleSessionRequest);
293
+ app.delete('/mcp', handleSessionRequest);
294
+ app.listen(port, host, () => {
295
+ console.error(`MCP Server listening on http://${host}:${port}/mcp`);
296
+ console.error(`OAuth resource metadata: ${getOAuthProtectedResourceMetadataUrl(resourceServerUrl)}`);
297
+ });
298
+ }
299
+ else {
300
+ const transport = new StdioServerTransport();
301
+ const server = createMcpServer(client);
302
+ await server.connect(transport);
303
+ console.error('MCP Server running on stdio');
304
+ }
305
+ }
306
+ main().catch((error) => {
307
+ console.error("Fatal error:", error);
308
+ process.exit(1);
309
+ });
@@ -0,0 +1,31 @@
1
+ const TEMPLATES_URI = "hirehuman://templates";
2
+ const templates = [
3
+ {
4
+ id: "moving",
5
+ name: "Moving Help",
6
+ description: "Help moving furniture or boxes",
7
+ priceRange: "$40-80",
8
+ typicalDuration: "2-4 hours"
9
+ },
10
+ {
11
+ id: "cleaning",
12
+ name: "House Cleaning",
13
+ description: "Standard house cleaning",
14
+ priceRange: "$80-150",
15
+ typicalDuration: "3-5 hours"
16
+ }
17
+ ];
18
+ export function registerAllResources(server, _client) {
19
+ server.registerResource('task-templates', TEMPLATES_URI, {
20
+ title: 'Task Templates',
21
+ description: 'List of common task templates with pricing guidance',
22
+ }, async () => ({
23
+ contents: [
24
+ {
25
+ uri: TEMPLATES_URI,
26
+ mimeType: 'application/json',
27
+ text: JSON.stringify(templates, null, 2),
28
+ },
29
+ ],
30
+ }));
31
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+ import { requireScopes } from "../auth/access.js";
3
+ const OUTPUT_TEMPLATE = 'ui://widget/hirehuman.html';
4
+ function oauthSecuritySchemes(scopes) {
5
+ return [
6
+ {
7
+ type: 'oauth2',
8
+ scopes,
9
+ },
10
+ ];
11
+ }
12
+ function toolDescriptorMeta(invoking, invoked, scopes) {
13
+ return {
14
+ 'openai/outputTemplate': OUTPUT_TEMPLATE,
15
+ 'openai/toolInvocation/invoking': invoking,
16
+ 'openai/toolInvocation/invoked': invoked,
17
+ 'openai/widgetAccessible': true,
18
+ securitySchemes: oauthSecuritySchemes(scopes),
19
+ };
20
+ }
21
+ function toolInvocationMeta(invoking, invoked, widgetSessionId) {
22
+ return {
23
+ 'openai/toolInvocation/invoking': invoking,
24
+ 'openai/toolInvocation/invoked': invoked,
25
+ 'openai/widgetSessionId': widgetSessionId,
26
+ };
27
+ }
28
+ // No input needed - just returns current user's account info
29
+ const GetAccountSchema = z.object({}).describe("No parameters required");
30
+ export function registerAccountTools(server, client) {
31
+ const getAccountInvoking = 'Fetching your account details';
32
+ const getAccountInvoked = 'Account details ready';
33
+ const getAccountHandler = async (_args, extra) => {
34
+ try {
35
+ const auth = requireScopes(extra, ['account:read']);
36
+ const result = await client.getAccount(auth.token);
37
+ const account = result.data;
38
+ const widgetSessionId = `account:${account.userId}`;
39
+ return {
40
+ structuredContent: {
41
+ account: {
42
+ displayName: account.displayName,
43
+ email: account.email,
44
+ balanceDollars: account.balanceDollars,
45
+ hasPaymentMethod: account.hasPaymentMethod,
46
+ },
47
+ },
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: `Welcome, ${account.displayName}! Your Haptic Paper balance is $${account.balanceDollars.toFixed(2)}.${account.hasPaymentMethod ? '' : ' Add a payment method to create paid tasks.'}`,
52
+ },
53
+ ],
54
+ _meta: {
55
+ ...toolInvocationMeta(getAccountInvoking, getAccountInvoked, widgetSessionId),
56
+ account,
57
+ },
58
+ };
59
+ }
60
+ catch (err) {
61
+ return {
62
+ content: [
63
+ {
64
+ type: 'text',
65
+ text: `Error fetching account: ${err.response?.data?.error?.message || err.message || 'Unknown error'}`,
66
+ },
67
+ ],
68
+ isError: true,
69
+ };
70
+ }
71
+ };
72
+ // Register as both naming conventions for compatibility
73
+ for (const toolName of ['get_account', 'account_get']) {
74
+ server.registerTool(toolName, {
75
+ title: 'Get account details',
76
+ description: 'Get your Haptic Paper account information including display name, email, and credit balance. Use this to check how much credit is available for creating tasks.',
77
+ inputSchema: GetAccountSchema,
78
+ annotations: {
79
+ readOnlyHint: true,
80
+ openWorldHint: false,
81
+ destructiveHint: false,
82
+ },
83
+ _meta: toolDescriptorMeta(getAccountInvoking, getAccountInvoked, ['account:read']),
84
+ }, getAccountHandler);
85
+ }
86
+ }
@@ -0,0 +1,123 @@
1
+ import { z } from "zod";
2
+ import { requireScopes } from "../auth/access.js";
3
+ import crypto from 'node:crypto';
4
+ const OUTPUT_TEMPLATE = 'ui://widget/hirehuman.html';
5
+ function stableSessionId(prefix, value) {
6
+ const raw = value ?? 'unknown';
7
+ const hash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
8
+ return `${prefix}:${hash}`;
9
+ }
10
+ function oauthSecuritySchemes(scopes) {
11
+ return [
12
+ {
13
+ type: 'oauth2',
14
+ scopes,
15
+ },
16
+ ];
17
+ }
18
+ function toolDescriptorMeta(invoking, invoked, scopes) {
19
+ return {
20
+ 'openai/outputTemplate': OUTPUT_TEMPLATE,
21
+ 'openai/toolInvocation/invoking': invoking,
22
+ 'openai/toolInvocation/invoked': invoked,
23
+ 'openai/widgetAccessible': true,
24
+ securitySchemes: oauthSecuritySchemes(scopes),
25
+ };
26
+ }
27
+ function toolInvocationMeta(invoking, invoked, widgetSessionId) {
28
+ return {
29
+ 'openai/toolInvocation/invoking': invoking,
30
+ 'openai/toolInvocation/invoked': invoked,
31
+ 'openai/widgetSessionId': widgetSessionId,
32
+ };
33
+ }
34
+ export function registerEstimateTools(server, client) {
35
+ const getEstimateInvoking = 'Calculating estimate';
36
+ const getEstimateInvoked = 'Estimate ready';
37
+ const getEstimateHandler = async (args, extra) => {
38
+ try {
39
+ const auth = requireScopes(extra, ['tasks:read']);
40
+ const est = await client.getEstimate(args, auth.token);
41
+ const widgetSessionId = stableSessionId('estimate', JSON.stringify({ userId: auth.userId ?? auth.clientId, args }));
42
+ return {
43
+ structuredContent: {
44
+ estimate: {
45
+ estimatedPrice: est.estimatedPrice,
46
+ priceRange: est.priceRange,
47
+ estimatedCompletionTime: est.estimatedCompletionTime,
48
+ workersAvailable: est.workersAvailable,
49
+ },
50
+ },
51
+ content: [
52
+ {
53
+ type: 'text',
54
+ text: `Estimate: $${est.estimatedPrice} (${est.priceRange?.min}-${est.priceRange?.max}), time: ${est.estimatedCompletionTime}`,
55
+ },
56
+ ],
57
+ _meta: {
58
+ ...toolInvocationMeta(getEstimateInvoking, getEstimateInvoked, widgetSessionId),
59
+ estimate: est,
60
+ },
61
+ };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
66
+ isError: true,
67
+ };
68
+ }
69
+ };
70
+ for (const toolName of ['get_estimate', 'estimates_get']) {
71
+ server.registerTool(toolName, {
72
+ title: 'Get estimate',
73
+ description: 'Get a price and time estimate for a task. Recommended before creating a task.',
74
+ inputSchema: z.object({
75
+ description: z.string().describe('Task description'),
76
+ urgency: z.enum(['flexible', 'today', 'urgent']).optional(),
77
+ location: z.object({ address: z.string() }).optional(),
78
+ }),
79
+ annotations: {
80
+ readOnlyHint: true,
81
+ openWorldHint: false,
82
+ destructiveHint: false,
83
+ },
84
+ _meta: toolDescriptorMeta(getEstimateInvoking, getEstimateInvoked, ['tasks:read']),
85
+ }, getEstimateHandler);
86
+ }
87
+ const listSkillsInvoking = 'Loading skill categories';
88
+ const listSkillsInvoked = 'Skill categories ready';
89
+ const listSkillCategoriesHandler = async (_args, extra) => {
90
+ try {
91
+ const auth = requireScopes(extra, ['tasks:read']);
92
+ const cats = await client.getSkillCategories();
93
+ const widgetSessionId = stableSessionId('skills', auth.userId ?? auth.clientId);
94
+ const text = cats
95
+ .map((c) => `### ${c.name}\n${c.description}\nRange: $${c.priceRange.min}-$${c.priceRange.max}`)
96
+ .join('\n\n');
97
+ return {
98
+ structuredContent: { categories: cats },
99
+ content: [{ type: 'text', text }],
100
+ _meta: {
101
+ ...toolInvocationMeta(listSkillsInvoking, listSkillsInvoked, widgetSessionId),
102
+ categories: cats,
103
+ },
104
+ };
105
+ }
106
+ catch (err) {
107
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
108
+ }
109
+ };
110
+ for (const toolName of ['list_skill_categories', 'skills_list_categories']) {
111
+ server.registerTool(toolName, {
112
+ title: 'List skill categories',
113
+ description: 'List available skill categories and typical pricing ranges.',
114
+ inputSchema: z.object({}),
115
+ annotations: {
116
+ readOnlyHint: true,
117
+ openWorldHint: false,
118
+ destructiveHint: false,
119
+ },
120
+ _meta: toolDescriptorMeta(listSkillsInvoking, listSkillsInvoked, ['tasks:read']),
121
+ }, listSkillCategoriesHandler);
122
+ }
123
+ }
@@ -0,0 +1,12 @@
1
+ import { registerTaskTools } from "./tasks.js";
2
+ import { registerWorkerTools } from "./workers.js";
3
+ import { registerEstimateTools } from "./estimates.js";
4
+ import { registerQualificationTools } from "./qualification.js";
5
+ import { registerAccountTools } from "./account.js";
6
+ export function registerAllTools(server, client) {
7
+ registerAccountTools(server, client);
8
+ registerTaskTools(server, client);
9
+ registerWorkerTools(server, client);
10
+ registerEstimateTools(server, client);
11
+ registerQualificationTools(server, client);
12
+ }