@askingahuman/mcp-server 1.0.0 → 2.0.1

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.
@@ -0,0 +1,380 @@
1
+ import { ApiError, apiErrorText, callApi, dollars, errorResult, textResult } from '../context.js';
2
+ import { LiveChatError } from '../live/bridge.js';
3
+ import { UNTRUSTED_NOTE, checkOutgoing, untrusted } from '../safety.js';
4
+ import { blockPrice, describeQuote, fetchQuote, humanIdFrom, HUMAN_ID_REQUIRED, spendLimitCents } from './discover.js';
5
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ export const MAX_CHAT_CHARS = 1000;
7
+ const MAX_WAIT_SECONDS = 50;
8
+ const POLL_MS = 3000;
9
+ // Below this much paid time left, tools warn and point to extend_session.
10
+ export const LOW_TIME_MS = 10 * 60_000;
11
+ function sessionIdFrom(args) {
12
+ const id = args?.session_id;
13
+ return typeof id === 'string' && SESSION_ID.test(id) ? id : null;
14
+ }
15
+ function waitSeconds(args, fallback) {
16
+ const n = Number(args?.wait_seconds);
17
+ return Math.min(MAX_WAIT_SECONDS, Math.max(0, Number.isFinite(n) ? n : fallback));
18
+ }
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ // Asks the client's user to approve a charge. Returns a result to stop with,
21
+ // or null to go ahead.
22
+ async function approve(ctx, message, what) {
23
+ if (ctx.config.skipApproval && !ctx.config.requireConfirmation)
24
+ return null;
25
+ const decision = await ctx.confirm(message);
26
+ if (decision === 'declined')
27
+ return textResult(`The user declined the cost, so ${what} and nothing was charged.`);
28
+ if (decision === 'cancelled') {
29
+ return textResult(`The cost approval was closed without an answer, or this client did not show it, so ${what} and nothing was charged. ` +
30
+ 'If the approval form never appeared, whoever runs this MCP server can set ASKINGAHUMAN_SKIP_APPROVAL=true ' +
31
+ '(the spend limit still applies).');
32
+ }
33
+ if (decision === 'unsupported' && ctx.config.requireConfirmation) {
34
+ return errorResult(`Not done: this MCP server requires the user to approve each charge, and this client cannot show a confirmation, so ${what}.`);
35
+ }
36
+ return null;
37
+ }
38
+ // "Paid time left: 42 min (until 14:05 UTC)." plus a low-time warning, from
39
+ // GET /api/connect/{id}. Empty before the person accepts.
40
+ export function paidTimeNote(data, now = Date.now()) {
41
+ if (typeof data.endsAt !== 'string')
42
+ return '';
43
+ const endsAt = new Date(data.endsAt);
44
+ if (Number.isNaN(endsAt.getTime()))
45
+ return '';
46
+ const leftMs = endsAt.getTime() - now;
47
+ const until = `${endsAt.toISOString().slice(11, 16)} UTC`;
48
+ if (leftMs <= 0)
49
+ return `The paid time ran out at ${until}.`;
50
+ let note = `Paid time left: ${Math.ceil(leftMs / 60_000)} min (until ${until}).`;
51
+ if (leftMs < LOW_TIME_MS) {
52
+ note += data.canExtend
53
+ ? ` Less than 10 minutes left: call extend_session to add another ${blockSpan(data.blockMinutes)} if you need it.`
54
+ : ' Less than 10 minutes left, and this chat is at its time or spend limit, so it cannot be extended.';
55
+ }
56
+ return note;
57
+ }
58
+ function blockSpan(blockMinutes) {
59
+ return Number(blockMinutes) === 60 ? 'hour' : `${blockMinutes ?? '?'} minutes`;
60
+ }
61
+ export const connectHumanTool = {
62
+ name: 'connect_human',
63
+ description: 'Request a PAID live text chat with a specific person (human_id). Chat is sold by the hour at the price the ' +
64
+ 'person sets: the first hour is held on the card when you request and charged in full once they accept ' +
65
+ '(nothing if they never do); extend_session adds another hour. Ending early does not refund paid time. ' +
66
+ 'If the person is offline, the request waits up to 24 hours for them. Call get_quote first; the user may be ' +
67
+ 'asked to approve the cost, and spending never exceeds the limit set by whoever runs this server. All sales ' +
68
+ 'are final. Then call check_connection to wait for the person to accept; you join the chat automatically.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ human_id: { type: 'string', description: 'The person to chat with (humanId from list_humans or who_is_live).' },
73
+ message: {
74
+ type: 'string',
75
+ description: 'What you need help with, shown to the person before they accept (max 500 characters). No links or credentials.',
76
+ maxLength: 500,
77
+ },
78
+ max_spend_usd: {
79
+ type: 'number',
80
+ description: 'Optional: the most to spend on the whole chat, extensions included, in US dollars.',
81
+ },
82
+ },
83
+ required: ['human_id', 'message'],
84
+ },
85
+ };
86
+ export async function handleConnectHuman(args, ctx) {
87
+ if (!ctx.live) {
88
+ return errorResult('Live chat is not available on the hosted MCP server. Run the local server (npx -y @askingahuman/mcp-server) to chat live, or use ask_human.');
89
+ }
90
+ if (!(await ctx.live.isAvailable())) {
91
+ return errorResult('Live chat needs the optional package @livekit/rtc-node, which is not installed here. Use ask_human instead, ' +
92
+ 'or reinstall @askingahuman/mcp-server with optional dependencies enabled.');
93
+ }
94
+ const humanId = humanIdFrom(args);
95
+ if (!humanId)
96
+ return errorResult(HUMAN_ID_REQUIRED);
97
+ const message = checkOutgoing(args?.message, { apiKey: ctx.apiKey, maxChars: 500 });
98
+ if (!message.ok)
99
+ return errorResult(message.reason);
100
+ // Price first, so the person running the agent approves the actual cost.
101
+ const quoted = await fetchQuote(args, ctx);
102
+ if (!quoted.ok)
103
+ return quoted.result;
104
+ const quote = quoted.quote;
105
+ if (!quote.bookable || quote.paymentReady === false)
106
+ return textResult(describeQuote(quote));
107
+ const stop = await approve(ctx, `Your AI agent wants to request a paid live chat on askingahuman.\n\n${describeQuote(quote)}\n\nApprove this chat?`, 'no chat was requested');
108
+ if (stop)
109
+ return stop;
110
+ const body = { mode: 'message', humanId, message: message.text, maxSpendCents: spendLimitCents(args, ctx) };
111
+ try {
112
+ const res = await callApi(ctx, '/api/connect', { method: 'POST', body });
113
+ if (res.status === 404)
114
+ return errorResult('No such person. Use list_humans or who_is_live to find someone.');
115
+ if (res.status === 409) {
116
+ // HUMAN_OFFLINE, OVER_SPEND_LIMIT, or the person doesn't take chats.
117
+ const why = typeof res.data.message === 'string' ? res.data.message : apiErrorText(res, 'Not available');
118
+ return textResult(`Not started: ${why}${/charged/i.test(why) ? '' : ' Nothing was charged.'}`);
119
+ }
120
+ if (res.status === 402) {
121
+ return errorResult('Payment required: add a card at https://askingahuman.com/developers, then try again. Nothing was charged.');
122
+ }
123
+ if (!res.ok)
124
+ return errorResult(`${apiErrorText(res, 'Failed to request a chat')}. Manage your key at https://askingahuman.com/developers`);
125
+ const sessionId = String(res.data.sessionId);
126
+ ctx.live.trackRequest(sessionId);
127
+ const human = (res.data.human || {});
128
+ const waits = typeof res.data.waitsUntil === 'string' ? ` (until ${res.data.waitsUntil})` : '';
129
+ return textResult(`Chat requested. The person has been notified${human.live ? '' : ` and the request waits for them up to 24 hours${waits}`}.\n` +
130
+ `Session ID: ${sessionId}\n` +
131
+ `Price: ${blockPrice(res.data.pricing, 'message')}; ${dollars(res.data.holdAmountCents)} held now, charged in full once they accept.\n\n` +
132
+ `Next: call check_connection with session_id "${sessionId}" and wait_seconds 45 until they accept. ` +
133
+ `To cancel before they accept, call end_session (the hold is released).`);
134
+ }
135
+ catch (err) {
136
+ return errorResult(err instanceof ApiError ? err.message : 'Failed to request a chat.');
137
+ }
138
+ }
139
+ export const checkConnectionTool = {
140
+ name: 'check_connection',
141
+ description: 'Check a live chat requested with connect_human, optionally waiting up to 50 seconds for the person to accept. ' +
142
+ 'When they accept you join the chat automatically; then use read_messages and send_message. ' +
143
+ 'Shows how much paid time is left. When the session has ended, shows the amount charged.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ session_id: { type: 'string', description: 'The Session ID from connect_human.' },
148
+ wait_seconds: { type: 'number', description: 'Wait up to this many seconds (0-50) for the person to accept. Default 0.' },
149
+ },
150
+ required: ['session_id'],
151
+ },
152
+ };
153
+ export async function handleCheckConnection(args, ctx) {
154
+ const sessionId = sessionIdFrom(args);
155
+ if (!sessionId)
156
+ return errorResult('Error: session_id must be the Session ID returned by connect_human.');
157
+ const deadline = Date.now() + waitSeconds(args, 0) * 1000;
158
+ try {
159
+ let res = await callApi(ctx, `/api/connect/${sessionId}`);
160
+ while (res.ok && res.data.status === 'REQUESTED' && Date.now() + POLL_MS < deadline) {
161
+ await sleep(POLL_MS);
162
+ res = await callApi(ctx, `/api/connect/${sessionId}`);
163
+ }
164
+ if (!res.ok)
165
+ return errorResult(apiErrorText(res, 'Failed to check the session'));
166
+ const status = String(res.data.status);
167
+ if (status === 'REQUESTED') {
168
+ return textResult('Still waiting for the person to accept. Call check_connection again with wait_seconds 45. ' +
169
+ 'Nothing is charged unless they accept; call end_session to cancel and release the hold.');
170
+ }
171
+ if (status === 'ENDED')
172
+ return textResult(`The session has ended. ${chargedText(res.data.amountCents)}`);
173
+ if (status === 'ACCEPTED' || status === 'ACTIVE') {
174
+ if (!ctx.live)
175
+ return errorResult('Live chat is not available on the hosted MCP server.');
176
+ if (typeof res.data.url !== 'string' || typeof res.data.token !== 'string') {
177
+ return errorResult('The session is ready but the join details are missing. Try check_connection again.');
178
+ }
179
+ // The join token stays inside this server; it is never shown to the model.
180
+ const joined = await ctx.live.join(sessionId, res.data.url, res.data.token);
181
+ const time = paidTimeNote(res.data);
182
+ return textResult(`${joined === 'joined' ? 'You joined the live chat.' : 'You are in the live chat.'} ` +
183
+ `Use read_messages (with wait_seconds) to hear from the person and send_message to reply.` +
184
+ (time ? `
185
+ ${time}` : '') +
186
+ `
187
+ The chat ends when the paid time runs out; extend_session adds time. Call end_session when you are done ` +
188
+ `(paid time is not refunded).`);
189
+ }
190
+ return textResult(`The session is ${status.toLowerCase()} (no charge). Try connect_human again later, or ask_human.`);
191
+ }
192
+ catch (err) {
193
+ if (err instanceof ApiError || err instanceof LiveChatError) {
194
+ return errorResult(`${err.message} Try check_connection again in a moment.`);
195
+ }
196
+ return errorResult('Failed to check the session.');
197
+ }
198
+ }
199
+ export const sendMessageTool = {
200
+ name: 'send_message',
201
+ description: 'Send a chat message to the person in a live session you have joined. Plain text only, up to 1000 characters; ' +
202
+ 'links and credentials are blocked.',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ session_id: { type: 'string', description: 'The Session ID.' },
207
+ text: { type: 'string', description: 'Your message (max 1000 characters).' },
208
+ },
209
+ required: ['session_id', 'text'],
210
+ },
211
+ };
212
+ export async function handleSendMessage(args, ctx) {
213
+ const sessionId = sessionIdFrom(args);
214
+ if (!sessionId)
215
+ return errorResult('Error: session_id must be the Session ID returned by connect_human.');
216
+ if (!ctx.live)
217
+ return errorResult('Live chat is not available on the hosted MCP server.');
218
+ const text = checkOutgoing(args?.text, { apiKey: ctx.apiKey, maxChars: MAX_CHAT_CHARS, field: 'text' });
219
+ if (!text.ok)
220
+ return errorResult(text.reason);
221
+ try {
222
+ await ctx.live.send(sessionId, text.text);
223
+ return textResult('Sent. Use read_messages with wait_seconds to wait for the reply.');
224
+ }
225
+ catch (err) {
226
+ return errorResult(err instanceof LiveChatError ? err.message : 'Failed to send the message.');
227
+ }
228
+ }
229
+ export const readMessagesTool = {
230
+ name: 'read_messages',
231
+ description: 'Read new messages from the person in a live session, waiting up to 50 seconds for one to arrive. ' +
232
+ 'Returns only messages you have not read yet, plus notices such as the person leaving or the chat ending, ' +
233
+ 'and how much paid time is left.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ session_id: { type: 'string', description: 'The Session ID.' },
238
+ wait_seconds: { type: 'number', description: 'Wait up to this many seconds (0-50) for a new message. Default 30.' },
239
+ },
240
+ required: ['session_id'],
241
+ },
242
+ };
243
+ // Best effort: a failed status check never hides the messages.
244
+ async function paidTimeFor(ctx, sessionId) {
245
+ try {
246
+ const res = await callApi(ctx, `/api/connect/${sessionId}`, { timeoutMs: 10_000 });
247
+ return res.ok ? paidTimeNote(res.data) : '';
248
+ }
249
+ catch {
250
+ return '';
251
+ }
252
+ }
253
+ function chargedText(amountCents) {
254
+ return Number(amountCents) > 0 ? `Charged ${dollars(amountCents)}.` : 'Nothing was charged; any hold is released.';
255
+ }
256
+ function formatLines(lines) {
257
+ return lines
258
+ .map((l) => (l.from === 'human' ? untrusted(l.text, `at="${l.at}"`) : `[${l.at}] Notice: ${l.text}`))
259
+ .join('\n');
260
+ }
261
+ export async function handleReadMessages(args, ctx) {
262
+ const sessionId = sessionIdFrom(args);
263
+ if (!sessionId)
264
+ return errorResult('Error: session_id must be the Session ID returned by connect_human.');
265
+ if (!ctx.live)
266
+ return errorResult('Live chat is not available on the hosted MCP server.');
267
+ try {
268
+ const { lines, closed, humanPresent } = await ctx.live.read(sessionId, waitSeconds(args, 30) * 1000);
269
+ if (lines.length === 0) {
270
+ if (closed)
271
+ return textResult('The chat has ended. Use check_connection to see what was charged.');
272
+ const time = await paidTimeFor(ctx, sessionId);
273
+ return textResult((humanPresent
274
+ ? 'No new messages yet. Call read_messages again to keep waiting, or send_message.'
275
+ : 'No new messages, and the person is not in the chat right now. Call read_messages again to keep waiting.') +
276
+ (time ? `\n${time}` : ''));
277
+ }
278
+ const time = closed ? '' : await paidTimeFor(ctx, sessionId);
279
+ const footer = closed
280
+ ? '\n\nThe chat has ended. Use check_connection to see what was charged.'
281
+ : `\n\nReply with send_message, or call end_session when you are done.${time ? `\n${time}` : ''}`;
282
+ return textResult(`${UNTRUSTED_NOTE}\n\n${formatLines(lines)}${footer}`);
283
+ }
284
+ catch (err) {
285
+ return errorResult(err instanceof LiveChatError ? err.message : 'Failed to read messages.');
286
+ }
287
+ }
288
+ export const endSessionTool = {
289
+ name: 'end_session',
290
+ description: 'End a live session and leave the chat. A request the person has not accepted yet is cancelled and its hold ' +
291
+ 'released. Once they accepted, every hour bought is charged in full, so ending early does not refund paid time. ' +
292
+ 'Call this when you are done.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ session_id: { type: 'string', description: 'The Session ID.' },
297
+ },
298
+ required: ['session_id'],
299
+ },
300
+ };
301
+ export async function endSessionViaApi(ctx, sessionId) {
302
+ const res = await callApi(ctx, `/api/connect/${sessionId}/end`, { method: 'POST', body: {} });
303
+ if (!res.ok && res.status !== 404 && res.status !== 409) {
304
+ throw new ApiError(apiErrorText(res, 'Failed to end the session'));
305
+ }
306
+ }
307
+ export async function handleEndSession(args, ctx) {
308
+ const sessionId = sessionIdFrom(args);
309
+ if (!sessionId)
310
+ return errorResult('Error: session_id must be the Session ID returned by connect_human.');
311
+ try {
312
+ if (ctx.live)
313
+ await ctx.live.end(sessionId);
314
+ else
315
+ await endSessionViaApi(ctx, sessionId);
316
+ const res = await callApi(ctx, `/api/connect/${sessionId}`);
317
+ if (res.ok && res.data.status === 'ENDED')
318
+ return textResult(`Session ended. ${chargedText(res.data.amountCents)}`);
319
+ return textResult('Session ended. Use check_connection in a moment to see the final charge.');
320
+ }
321
+ catch (err) {
322
+ return errorResult(err instanceof ApiError ? err.message : 'Failed to end the session. Try again.');
323
+ }
324
+ }
325
+ export const extendSessionTool = {
326
+ name: 'extend_session',
327
+ description: 'Add one more paid block (an hour of chat) to a live chat that has started, charged in full right away to the ' +
328
+ 'same card or balance. The user may be asked to approve the cost. Limited by the session\'s time and spend ' +
329
+ 'limits. Use it when check_connection or read_messages says paid time is running low and you still need the person.',
330
+ inputSchema: {
331
+ type: 'object',
332
+ properties: {
333
+ session_id: { type: 'string', description: 'The Session ID.' },
334
+ },
335
+ required: ['session_id'],
336
+ },
337
+ };
338
+ export async function handleExtendSession(args, ctx) {
339
+ const sessionId = sessionIdFrom(args);
340
+ if (!sessionId)
341
+ return errorResult('Error: session_id must be the Session ID returned by connect_human.');
342
+ try {
343
+ const current = await callApi(ctx, `/api/connect/${sessionId}`);
344
+ if (current.status === 404)
345
+ return errorResult('No such session.');
346
+ if (!current.ok)
347
+ return errorResult(apiErrorText(current, 'Failed to check the session'));
348
+ const data = current.data;
349
+ if (data.status === 'REQUESTED')
350
+ return textResult('The person has not accepted yet; extend once the chat has started.');
351
+ if (data.status !== 'ACCEPTED' && data.status !== 'ACTIVE')
352
+ return textResult('This session has ended, so it cannot be extended.');
353
+ if (!data.canExtend) {
354
+ return textResult(`This chat is at its time or spend limit, so it cannot be extended. ${paidTimeNote(data)}`.trim());
355
+ }
356
+ const pricing = (data.pricing || {});
357
+ const blocks = Number(data.blocksPurchased ?? 1);
358
+ const stop = await approve(ctx, `Your AI agent wants to extend a live chat on askingahuman by one more ${blockSpan(data.blockMinutes)}.\n\n` +
359
+ `Cost: ${dollars(pricing.blockPriceCents)}, charged in full now. Session total: ${dollars((blocks + 1) * Number(pricing.blockPriceCents))}.\n` +
360
+ `All sales are final.\n\nApprove this extension?`, 'the chat was not extended');
361
+ if (stop)
362
+ return stop;
363
+ const res = await callApi(ctx, `/api/connect/${sessionId}/extend`, { method: 'POST', body: {} });
364
+ if (res.status === 402) {
365
+ return errorResult('Payment required: the extra hour could not be authorized on the card or balance. The chat was not extended.');
366
+ }
367
+ if (res.status === 409) {
368
+ const why = typeof res.data.message === 'string' ? res.data.message : apiErrorText(res, 'Cannot extend');
369
+ return textResult(`Not extended: ${why}`);
370
+ }
371
+ if (!res.ok)
372
+ return errorResult(apiErrorText(res, 'Failed to extend the session'));
373
+ const endsAt = typeof res.data.endsAt === 'string' ? `${res.data.endsAt.slice(11, 16)} UTC (${res.data.endsAt})` : 'unknown';
374
+ return textResult(`Extended by one ${blockSpan(res.data.blockMinutes)}. Paid time now runs until ${endsAt}.\n` +
375
+ `Session total so far: ${dollars(res.data.totalCents)} (${res.data.blocksPurchased} of up to ${res.data.maxBlocks} blocks).`);
376
+ }
377
+ catch (err) {
378
+ return errorResult(err instanceof ApiError ? err.message : 'Failed to extend the session.');
379
+ }
380
+ }
@@ -0,0 +1,14 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ export declare const replyToHumanTool: Tool;
3
+ export declare function handleReplyToHuman(args: Record<string, unknown> | undefined, apiKey: string, apiBase: string): Promise<{
4
+ content: {
5
+ type: "text";
6
+ text: string;
7
+ }[];
8
+ isError: boolean;
9
+ } | {
10
+ content: {
11
+ type: "text";
12
+ text: string;
13
+ }[];
14
+ }>;
@@ -0,0 +1,72 @@
1
+ import { checkOutgoing } from '../safety.js';
2
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const MAX_REPLY_CHARS = 2000;
4
+ export const replyToHumanTool = {
5
+ name: 'reply_to_human',
6
+ description: 'Continue the conversation with the human who answered your question at askingahuman.com: ' +
7
+ 'ask a follow-up, clarify, or say thanks. Only works after the question is answered. ' +
8
+ 'The human is notified on their phone. Use check_answer to read their response.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ question_id: {
13
+ type: 'string',
14
+ description: 'The questionId returned by ask_human (the answered question).',
15
+ },
16
+ message: {
17
+ type: 'string',
18
+ description: `Your message to the human (max ${MAX_REPLY_CHARS} characters).`,
19
+ },
20
+ },
21
+ required: ['question_id', 'message'],
22
+ },
23
+ };
24
+ function errorResult(text) {
25
+ return { content: [{ type: 'text', text }], isError: true };
26
+ }
27
+ export async function handleReplyToHuman(args, apiKey, apiBase) {
28
+ const questionId = args?.question_id;
29
+ if (typeof questionId !== 'string' || !UUID_REGEX.test(questionId)) {
30
+ return errorResult('Error: question_id must be the exact questionId (a UUID) returned by ask_human.');
31
+ }
32
+ const checked = checkOutgoing(args?.message, { apiKey, maxChars: MAX_REPLY_CHARS });
33
+ if (!checked.ok) {
34
+ return errorResult(checked.reason === 'message is empty.' ? 'Error: message is required.' : `Error: ${checked.reason}`);
35
+ }
36
+ const message = checked.text;
37
+ const controller = new AbortController();
38
+ const timeoutId = setTimeout(() => controller.abort(), 30_000);
39
+ try {
40
+ const response = await fetch(`${apiBase}/api/questions/${questionId}/replies`, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
43
+ body: JSON.stringify({ text: message }),
44
+ signal: controller.signal,
45
+ });
46
+ const data = (await response.json().catch(() => ({})));
47
+ if (!response.ok) {
48
+ if (response.status === 400 && String(data.error).includes('answered')) {
49
+ return errorResult('The human has not answered this question yet, so there is no conversation to reply to. Use check_answer to wait for the answer.');
50
+ }
51
+ return errorResult(`Error (${response.status}): ${data.error || 'Failed to send reply'}`);
52
+ }
53
+ return {
54
+ content: [
55
+ {
56
+ type: 'text',
57
+ text: `Reply sent to the human (they are notified).\n\n` +
58
+ `Use check_answer with question_id "${questionId}" to see their response.`,
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ catch (err) {
64
+ if (err instanceof Error && err.name === 'AbortError') {
65
+ return errorResult('Request timed out after 30 seconds. Please try again.');
66
+ }
67
+ return errorResult(`Network error: ${err instanceof Error ? err.message : 'Unknown error'}.`);
68
+ }
69
+ finally {
70
+ clearTimeout(timeoutId);
71
+ }
72
+ }
@@ -0,0 +1,15 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ export declare const submitFeedbackTool: Tool;
3
+ export declare function handleSubmitFeedback(args: Record<string, unknown> | undefined, apiKey: string, apiBase: string): Promise<{
4
+ content: {
5
+ type: "text";
6
+ text: string;
7
+ }[];
8
+ isError: boolean;
9
+ } | {
10
+ content: {
11
+ type: "text";
12
+ text: string;
13
+ }[];
14
+ isError?: undefined;
15
+ }>;
@@ -0,0 +1,77 @@
1
+ export const submitFeedbackTool = {
2
+ name: 'submit_feedback',
3
+ description: 'Submit feedback about the askingahuman.com MCP server or service. ' +
4
+ 'Use this to report issues, suggest improvements, or provide general feedback from an AI agent.',
5
+ inputSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ feedback: {
9
+ type: 'string',
10
+ description: 'The feedback message. Be specific and detailed.',
11
+ },
12
+ contact_email: {
13
+ type: 'string',
14
+ description: 'Optional email address for follow-up.',
15
+ },
16
+ },
17
+ required: ['feedback'],
18
+ },
19
+ };
20
+ export async function handleSubmitFeedback(args, apiKey, apiBase) {
21
+ if (!args?.feedback || typeof args.feedback !== 'string') {
22
+ return {
23
+ content: [{ type: 'text', text: 'Error: The feedback parameter is required.' }],
24
+ isError: true,
25
+ };
26
+ }
27
+ const controller = new AbortController();
28
+ const timeoutId = setTimeout(() => controller.abort(), 30_000);
29
+ try {
30
+ const response = await fetch(`${apiBase}/api/feedback`, {
31
+ method: 'POST',
32
+ headers: {
33
+ Authorization: `Bearer ${apiKey}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ body: JSON.stringify({
37
+ feedback: args.feedback,
38
+ contactEmail: typeof args.contact_email === 'string' ? args.contact_email : undefined,
39
+ }),
40
+ signal: controller.signal,
41
+ });
42
+ const data = (await response.json());
43
+ if (!response.ok) {
44
+ return {
45
+ content: [
46
+ {
47
+ type: 'text',
48
+ text: `Error (${response.status}): ${data.error || 'Failed to submit feedback'}`,
49
+ },
50
+ ],
51
+ isError: true,
52
+ };
53
+ }
54
+ return {
55
+ content: [
56
+ {
57
+ type: 'text',
58
+ text: `Feedback submitted successfully. Thank you!`,
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text',
68
+ text: `Network error: ${err instanceof Error ? err.message : 'Unknown error'}.`,
69
+ },
70
+ ],
71
+ isError: true,
72
+ };
73
+ }
74
+ finally {
75
+ clearTimeout(timeoutId);
76
+ }
77
+ }
@@ -0,0 +1,4 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ import { ToolContext, ToolResult } from '../context.js';
3
+ export declare const whoIsLiveTool: Tool;
4
+ export declare function handleWhoIsLive(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
@@ -0,0 +1,53 @@
1
+ import { ApiError, apiErrorText, callApi, errorResult, textResult } from '../context.js';
2
+ import { cleanIncoming } from '../safety.js';
3
+ const MAX_WAIT_SECONDS = 50;
4
+ const POLL_MS = 5000;
5
+ export const whoIsLiveTool = {
6
+ name: 'who_is_live',
7
+ description: 'See which people are live right now and can take a live chat. Free; nobody is notified. ' +
8
+ 'Set wait_seconds to wait (up to 50 seconds) for someone to come online. ' +
9
+ 'Use get_quote with a humanId to see their price before connect_human.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ specialization: { type: 'string', description: 'Optional: only people with this specialization.', maxLength: 100 },
14
+ wait_seconds: { type: 'number', description: 'Wait up to this many seconds (0-50) for someone to be live. Default 0.' },
15
+ },
16
+ },
17
+ };
18
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+ export async function handleWhoIsLive(args, ctx) {
20
+ const query = new URLSearchParams();
21
+ if (typeof args?.specialization === 'string' && args.specialization.trim()) {
22
+ query.set('specialization', args.specialization.trim().slice(0, 100));
23
+ }
24
+ const wait = Math.min(MAX_WAIT_SECONDS, Math.max(0, Number(args?.wait_seconds) || 0));
25
+ const deadline = Date.now() + wait * 1000;
26
+ const qs = query.toString();
27
+ const path = `/api/live-now${qs ? `?${qs}` : ''}`;
28
+ try {
29
+ let res = await callApi(ctx, path);
30
+ while (res.ok && Number(res.data.count) === 0 && Date.now() + POLL_MS < deadline) {
31
+ await sleep(POLL_MS);
32
+ res = await callApi(ctx, path);
33
+ }
34
+ if (!res.ok)
35
+ return errorResult(apiErrorText(res, 'Failed to check who is live'));
36
+ const humans = res.data.humans || [];
37
+ if (humans.length === 0) {
38
+ return textResult(wait > 0
39
+ ? `Nobody came online in the last ${Math.round(wait)} seconds. A chat request (connect_human) waits up to 24 hours for an offline person, or ask_human sends them a free question.`
40
+ : 'Nobody is live right now. A chat request (connect_human) waits up to 24 hours for an offline person, or ask_human sends them a free question. Find people with list_humans.');
41
+ }
42
+ const short = (v, n) => cleanIncoming(String(v ?? ''), n).replace(/\s+/g, ' ').trim();
43
+ const lines = humans.map((h) => {
44
+ const specs = Array.isArray(h.specializations) ? h.specializations.slice(0, 6).map((s) => short(s, 40)).join(', ') : '';
45
+ return `- ${short(h.name, 60) || 'An expert'} (humanId: ${short(h.humanId, 80)})${specs ? ` — ${specs}` : ''}`;
46
+ });
47
+ return textResult(`${humans.length} ${humans.length === 1 ? 'person is' : 'people are'} live now:\n${lines.join('\n')}\n\n` +
48
+ 'Use get_quote with a humanId to see their price, then connect_human to request a live chat.');
49
+ }
50
+ catch (err) {
51
+ return errorResult(err instanceof ApiError ? err.message : 'Failed to check who is live.');
52
+ }
53
+ }