@askingahuman/mcp-server 1.0.0 → 2.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 +64 -52
- package/dist/config.d.ts +10 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +33 -0
- package/dist/context.js +42 -0
- package/dist/http-server.d.ts +1 -0
- package/dist/http-server.js +53 -0
- package/dist/index.js +31 -35
- package/dist/live/bridge.d.ts +68 -0
- package/dist/live/bridge.js +217 -0
- package/dist/safety.d.ts +16 -0
- package/dist/safety.js +70 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +70 -0
- package/dist/tools/ask-human.js +167 -38
- package/dist/tools/check-answer.js +99 -34
- package/dist/tools/discover.d.ts +18 -0
- package/dist/tools/discover.js +118 -0
- package/dist/tools/live.d.ts +18 -0
- package/dist/tools/live.js +380 -0
- package/dist/tools/reply-to-human.d.ts +14 -0
- package/dist/tools/reply-to-human.js +72 -0
- package/dist/tools/submit-feedback.d.ts +15 -0
- package/dist/tools/submit-feedback.js +77 -0
- package/dist/tools/who-is-live.d.ts +4 -0
- package/dist/tools/who-is-live.js +53 -0
- package/package.json +21 -8
|
@@ -1,86 +1,151 @@
|
|
|
1
|
+
import { UNTRUSTED_NOTE, untrusted } from '../safety.js';
|
|
1
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;
|
|
2
3
|
export const checkAnswerTool = {
|
|
3
4
|
name: 'check_answer',
|
|
4
5
|
description: 'Check if a human has answered your question at askingahuman.com. ' +
|
|
5
6
|
'Poll this tool periodically after calling ask_human — recommended interval is every 30 minutes or less. ' +
|
|
6
|
-
'
|
|
7
|
+
'An expert answers when they are free. ' +
|
|
7
8
|
'Returns the answer text if ready, or the current status if still pending.',
|
|
8
9
|
inputSchema: {
|
|
9
10
|
type: 'object',
|
|
10
11
|
properties: {
|
|
11
12
|
question_id: {
|
|
12
13
|
type: 'string',
|
|
13
|
-
description: 'The questionId returned by ask_human.'
|
|
14
|
-
}
|
|
14
|
+
description: 'The questionId returned by ask_human.',
|
|
15
|
+
},
|
|
15
16
|
},
|
|
16
|
-
required: ['question_id']
|
|
17
|
-
}
|
|
17
|
+
required: ['question_id'],
|
|
18
|
+
},
|
|
18
19
|
};
|
|
20
|
+
// Follow-up messages after the answer, oldest first, as a readable transcript.
|
|
21
|
+
// Best-effort: if the replies can't be loaded, the answer is still returned.
|
|
22
|
+
async function fetchConversation(questionId, apiKey, apiBase, signal) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`${apiBase}/api/questions/${questionId}/replies`, {
|
|
25
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
26
|
+
signal,
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok)
|
|
29
|
+
return '';
|
|
30
|
+
const data = (await response.json());
|
|
31
|
+
const replies = data.replies ?? [];
|
|
32
|
+
if (replies.length === 0)
|
|
33
|
+
return '';
|
|
34
|
+
const lines = replies.map((r) => r.authorType === 'human' ? untrusted(r.text, `at="${r.createdAt}"`) : `[${r.createdAt}] You: ${r.text}`);
|
|
35
|
+
const last = replies[replies.length - 1];
|
|
36
|
+
const turn = last.authorType === 'human'
|
|
37
|
+
? 'The human replied last — it is your turn to respond.'
|
|
38
|
+
: 'You replied last — waiting for the human.';
|
|
39
|
+
return `\n\nConversation since the answer (${replies.length} message${replies.length === 1 ? '' : 's'}):\n${lines.join('\n')}\n${turn}`;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
19
45
|
export async function handleCheckAnswer(args, apiKey, apiBase) {
|
|
20
46
|
if (!args?.question_id || typeof args.question_id !== 'string') {
|
|
21
47
|
return {
|
|
22
|
-
content: [
|
|
23
|
-
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: 'Error: The question_id parameter is required. You must provide the exact questionId that was returned when you called the ask_human tool.',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
isError: true,
|
|
24
55
|
};
|
|
25
56
|
}
|
|
26
57
|
if (!UUID_REGEX.test(args.question_id)) {
|
|
27
58
|
return {
|
|
28
|
-
content: [
|
|
29
|
-
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: 'text',
|
|
62
|
+
text: `Error: The question_id "${args.question_id}" is not a valid UUID format. Please ensure you are using the exact questionId string returned by the ask_human tool.`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
isError: true,
|
|
30
66
|
};
|
|
31
67
|
}
|
|
32
68
|
const controller = new AbortController();
|
|
33
69
|
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
34
70
|
try {
|
|
35
71
|
const response = await fetch(`${apiBase}/api/questions/${args.question_id}`, {
|
|
36
|
-
headers: {
|
|
37
|
-
signal: controller.signal
|
|
72
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
73
|
+
signal: controller.signal,
|
|
38
74
|
});
|
|
39
|
-
const data = await response.json();
|
|
75
|
+
const data = (await response.json());
|
|
40
76
|
if (!response.ok) {
|
|
77
|
+
if (response.status === 404) {
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: `Error (404): The question_id "${args.question_id}" was not found. Please verify that you have submitted the question successfully and are using the correct questionId.`,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
41
88
|
return {
|
|
42
|
-
content: [
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
43
91
|
type: 'text',
|
|
44
|
-
text: `Error (${response.status}): ${data.error || 'Failed to check question'}
|
|
45
|
-
}
|
|
46
|
-
|
|
92
|
+
text: `Error (${response.status}): ${data.error || 'Failed to check question status'}`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
isError: true,
|
|
47
96
|
};
|
|
48
97
|
}
|
|
98
|
+
const screenshotNote = data.hasScreenshot
|
|
99
|
+
? '\n(This question included a screenshot for human context.)\n'
|
|
100
|
+
: '';
|
|
49
101
|
if (data.status === 'ANSWERED') {
|
|
102
|
+
const conversation = Number(data.replyCount) > 0
|
|
103
|
+
? await fetchConversation(args.question_id, apiKey, apiBase, controller.signal)
|
|
104
|
+
: '';
|
|
50
105
|
return {
|
|
51
|
-
content: [
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
52
108
|
type: 'text',
|
|
53
|
-
text: `The human has answered your question.\n\n` +
|
|
54
|
-
`Question: ${data.text}\n\n` +
|
|
55
|
-
`Answer
|
|
56
|
-
`Answered at: ${data.answeredAt}`
|
|
57
|
-
|
|
109
|
+
text: `The human has answered your question.\n\n${UNTRUSTED_NOTE}\n\n` +
|
|
110
|
+
`Question: ${data.text}\n${screenshotNote}\n` +
|
|
111
|
+
`Answer:\n${untrusted(String(data.answer ?? ''))}\n\n` +
|
|
112
|
+
`Answered at: ${data.answeredAt}` +
|
|
113
|
+
conversation +
|
|
114
|
+
`\n\nTo ask a follow-up or respond, use reply_to_human with question_id "${args.question_id}".`,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
58
117
|
};
|
|
59
118
|
}
|
|
60
119
|
return {
|
|
61
|
-
content: [
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
62
122
|
type: 'text',
|
|
63
|
-
text: `Status: ${data.status}. The human has not answered yet
|
|
64
|
-
`Check back later
|
|
65
|
-
}
|
|
123
|
+
text: `Status: ${data.status}. The human has not answered yet.${screenshotNote} ` +
|
|
124
|
+
`Check back later; an expert answers when they are free.`,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
66
127
|
};
|
|
67
128
|
}
|
|
68
129
|
catch (err) {
|
|
69
130
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
70
131
|
return {
|
|
71
|
-
content: [
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
72
134
|
type: 'text',
|
|
73
|
-
text: 'Request timed out after 30 seconds. The askingahuman.com API may be temporarily unavailable.'
|
|
74
|
-
}
|
|
75
|
-
|
|
135
|
+
text: 'Request timed out after 30 seconds. The askingahuman.com API may be temporarily unavailable. Please try checking again later.',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
isError: true,
|
|
76
139
|
};
|
|
77
140
|
}
|
|
78
141
|
return {
|
|
79
|
-
content: [
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
80
144
|
type: 'text',
|
|
81
|
-
text: `Network error: ${err instanceof Error ? err.message : 'Unknown error'}
|
|
82
|
-
}
|
|
83
|
-
|
|
145
|
+
text: `Network error: ${err instanceof Error ? err.message : 'Unknown error'}. Please check your connection and try again.`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
isError: true,
|
|
84
149
|
};
|
|
85
150
|
}
|
|
86
151
|
finally {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { ToolContext, ToolResult } from '../context.js';
|
|
3
|
+
export declare const listHumansTool: Tool;
|
|
4
|
+
export declare function handleListHumans(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
5
|
+
export declare const getQuoteTool: Tool;
|
|
6
|
+
export declare function humanIdFrom(args: Record<string, unknown> | undefined): string | null;
|
|
7
|
+
export declare const HUMAN_ID_REQUIRED = "Error: human_id is required: choose the person with list_humans or who_is_live and pass their humanId.";
|
|
8
|
+
export declare function spendLimitCents(args: Record<string, unknown> | undefined, ctx: ToolContext): number;
|
|
9
|
+
export declare function fetchQuote(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<{
|
|
10
|
+
ok: true;
|
|
11
|
+
quote: Record<string, unknown>;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
result: ToolResult;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function blockPrice(pricing: unknown, mode?: unknown): string;
|
|
17
|
+
export declare function describeQuote(q: Record<string, unknown>): string;
|
|
18
|
+
export declare function handleGetQuote(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ApiError, apiErrorText, callApi, dollars, errorResult, textResult } from '../context.js';
|
|
2
|
+
import { cleanIncoming } from '../safety.js';
|
|
3
|
+
export const listHumansTool = {
|
|
4
|
+
name: 'list_humans',
|
|
5
|
+
description: 'List the reviewed experts on askingahuman.com: name, title, specializations, languages and rating. ' +
|
|
6
|
+
'Every request names a person: use a humanId from here with get_quote, connect_human or ask_human. ' +
|
|
7
|
+
'Profiles are public summaries only; no contact details are shared.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
specialty: { type: 'string', description: 'Optional: only experts with this specialization.', maxLength: 100 },
|
|
12
|
+
limit: { type: 'number', description: 'How many to return (1-25, default 10).' },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export async function handleListHumans(args, ctx) {
|
|
17
|
+
const limit = Math.min(25, Math.max(1, Math.floor(Number(args?.limit) || 10)));
|
|
18
|
+
const query = new URLSearchParams({ audience: 'bot', limit: String(limit) });
|
|
19
|
+
if (typeof args?.specialty === 'string' && args.specialty.trim())
|
|
20
|
+
query.set('specialty', args.specialty.trim().slice(0, 100));
|
|
21
|
+
try {
|
|
22
|
+
const res = await callApi(ctx, `/humans?${query}`);
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return errorResult(apiErrorText(res, 'Failed to list experts'));
|
|
25
|
+
const humans = res.data.humans || [];
|
|
26
|
+
if (humans.length === 0)
|
|
27
|
+
return textResult('No experts match that search.');
|
|
28
|
+
// Profile text is written by the experts themselves; keep it short and clean.
|
|
29
|
+
const short = (v, n) => cleanIncoming(String(v ?? ''), n).replace(/\s+/g, ' ').trim();
|
|
30
|
+
const lines = humans.map((h) => {
|
|
31
|
+
const specs = Array.isArray(h.specializations) ? h.specializations.map((s) => short(s, 40)).join(', ') : '';
|
|
32
|
+
const rating = typeof h.rating === 'number' && h.rating > 0 ? ` · rated ${h.rating.toFixed(1)}` : '';
|
|
33
|
+
return `- ${short(h.name, 60)} (humanId: ${short(h.humanId, 80)})${h.title ? ` — ${short(h.title, 80)}` : ''}${rating}` +
|
|
34
|
+
(specs ? `\n Specializations: ${specs}` : '');
|
|
35
|
+
});
|
|
36
|
+
return textResult(`${humans.length} expert${humans.length === 1 ? '' : 's'}:\n${lines.join('\n')}\n\n` +
|
|
37
|
+
'Prices and live status are not listed here; use get_quote with a humanId to see them.');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return errorResult(err instanceof ApiError ? err.message : 'Failed to list experts.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const getQuoteTool = {
|
|
44
|
+
name: 'get_quote',
|
|
45
|
+
description: 'Check, at no cost, what a live chat with a specific person would cost: their price per hour of chat, what is ' +
|
|
46
|
+
'held on the card, how long the chat can run with extensions, and whether they are live now. Nothing is charged ' +
|
|
47
|
+
'and nobody is notified. Call this before connect_human.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
human_id: { type: 'string', description: 'The person (humanId from list_humans or who_is_live).' },
|
|
52
|
+
max_spend_usd: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Optional: the most to spend on the whole chat, extensions included, in US dollars.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ['human_id'],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const HUMAN_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
61
|
+
// The person a tool call names, or null when missing or malformed.
|
|
62
|
+
export function humanIdFrom(args) {
|
|
63
|
+
const id = typeof args?.human_id === 'string' ? args.human_id.trim() : '';
|
|
64
|
+
return HUMAN_ID.test(id) ? id : null;
|
|
65
|
+
}
|
|
66
|
+
export const HUMAN_ID_REQUIRED = 'Error: human_id is required: choose the person with list_humans or who_is_live and pass their humanId.';
|
|
67
|
+
// The spend limit sent to the API: the agent's request, never above the operator's.
|
|
68
|
+
export function spendLimitCents(args, ctx) {
|
|
69
|
+
const asked = Number(args?.max_spend_usd);
|
|
70
|
+
return Math.max(1, Math.min(ctx.config.maxSpendCents, Number.isFinite(asked) && asked > 0 ? Math.round(asked * 100) : Infinity));
|
|
71
|
+
}
|
|
72
|
+
export async function fetchQuote(args, ctx) {
|
|
73
|
+
const humanId = humanIdFrom(args);
|
|
74
|
+
if (!humanId)
|
|
75
|
+
return { ok: false, result: errorResult(HUMAN_ID_REQUIRED) };
|
|
76
|
+
const query = new URLSearchParams({ mode: 'message', humanId, maxSpendCents: String(spendLimitCents(args, ctx)) });
|
|
77
|
+
try {
|
|
78
|
+
const res = await callApi(ctx, `/api/connect/quote?${query}`);
|
|
79
|
+
if (res.status === 404) {
|
|
80
|
+
return { ok: false, result: errorResult('No such person. Use list_humans or who_is_live to find someone.') };
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
return { ok: false, result: errorResult(apiErrorText(res, 'Failed to get a quote')) };
|
|
84
|
+
return { ok: true, quote: res.data };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return { ok: false, result: errorResult(err instanceof ApiError ? err.message : 'Failed to get a quote.') };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// "$120.00 per hour of chat", "$90.00 per 30 min of video".
|
|
91
|
+
export function blockPrice(pricing, mode = 'message') {
|
|
92
|
+
const p = (pricing || {});
|
|
93
|
+
const span = p.blockMinutes === 60 ? 'hour' : `${p.blockMinutes ?? '?'} min`;
|
|
94
|
+
return `${dollars(p.blockPriceCents)} per ${span} of ${mode === 'video' ? 'video' : 'chat'}`;
|
|
95
|
+
}
|
|
96
|
+
function hours(minutes) {
|
|
97
|
+
const n = Number(minutes);
|
|
98
|
+
return n % 60 === 0 ? `${n / 60} hour${n === 60 ? '' : 's'}` : `${n} minutes`;
|
|
99
|
+
}
|
|
100
|
+
export function describeQuote(q) {
|
|
101
|
+
const human = q.human;
|
|
102
|
+
const who = human ? `${cleanIncoming(String(human.name || 'This person'), 60)} (humanId: ${human.humanId})` : 'This person';
|
|
103
|
+
if (!q.bookable) {
|
|
104
|
+
const price = q.pricing ? ` Their price: ${blockPrice(q.pricing, q.mode)}.` : '';
|
|
105
|
+
return `${who}: ${String(q.message || 'a live chat is not available.')}${price} Nothing was charged.`;
|
|
106
|
+
}
|
|
107
|
+
return (`${who} is ${human?.live ? 'live now' : 'offline; a chat request waits up to 24 hours for them'}.\n` +
|
|
108
|
+
`Price: ${blockPrice(q.pricing, q.mode)}, charged in full once they accept (nothing if they never do). ` +
|
|
109
|
+
`${dollars(q.holdAmountCents)} is held on the card when you request.\n` +
|
|
110
|
+
`Length: one hour to start; extend_session adds another hour, up to ${hours(q.maxSessionMinutes)} ` +
|
|
111
|
+
`within your ${dollars(q.spendLimitCents)} spend limit. Ending early does not refund paid time.\n` +
|
|
112
|
+
`All sales are final.` +
|
|
113
|
+
(q.paymentReady === false ? `\nThis API key has no card on file: add one at https://askingahuman.com/developers before connecting.` : ''));
|
|
114
|
+
}
|
|
115
|
+
export async function handleGetQuote(args, ctx) {
|
|
116
|
+
const res = await fetchQuote(args, ctx);
|
|
117
|
+
return res.ok ? textResult(describeQuote(res.quote)) : res.result;
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { ToolContext, ToolResult } from '../context.js';
|
|
3
|
+
export declare const MAX_CHAT_CHARS = 1000;
|
|
4
|
+
export declare const LOW_TIME_MS: number;
|
|
5
|
+
export declare function paidTimeNote(data: Record<string, unknown>, now?: number): string;
|
|
6
|
+
export declare const connectHumanTool: Tool;
|
|
7
|
+
export declare function handleConnectHuman(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
8
|
+
export declare const checkConnectionTool: Tool;
|
|
9
|
+
export declare function handleCheckConnection(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
10
|
+
export declare const sendMessageTool: Tool;
|
|
11
|
+
export declare function handleSendMessage(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
12
|
+
export declare const readMessagesTool: Tool;
|
|
13
|
+
export declare function handleReadMessages(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
14
|
+
export declare const endSessionTool: Tool;
|
|
15
|
+
export declare function endSessionViaApi(ctx: Pick<ToolContext, 'apiKey' | 'config'>, sessionId: string): Promise<void>;
|
|
16
|
+
export declare function handleEndSession(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|
|
17
|
+
export declare const extendSessionTool: Tool;
|
|
18
|
+
export declare function handleExtendSession(args: Record<string, unknown> | undefined, ctx: ToolContext): Promise<ToolResult>;
|