webhound-mcp 0.2.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.
- package/README.md +179 -0
- package/bin/server.mjs +72 -0
- package/core/http.mjs +22 -0
- package/core/server.mjs +459 -0
- package/core/webhoundClient.mjs +320 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# webhound-mcp
|
|
2
|
+
|
|
3
|
+
Run Webhound from any MCP-speaking agent. Webhound creates private, budgeted reports and datasets, watches them over time, lets the agent steer a live run, diagnoses failures, and returns cited outputs with sources and claim traces.
|
|
4
|
+
|
|
5
|
+
This package is the local stdio transport. Webhound also supports hosted MCP at:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
https://api.webhound.ai/api/v2/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Create a Webhound API key, then add the stdio server to your agent:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"webhound": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["-y", "webhound-mcp"],
|
|
21
|
+
"env": {
|
|
22
|
+
"WEBHOUND_KEY": "wh_..."
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Claude hosted connector:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
https://api.webhound.ai/api/v2/mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Paste the URL into Claude's custom connector flow. The hosted server exposes OAuth discovery, authorize, and token endpoints for that connect flow.
|
|
36
|
+
|
|
37
|
+
Manus or generic hosted MCP:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
Server URL: https://api.webhound.ai/api/v2/mcp
|
|
41
|
+
Authentication: Bearer token
|
|
42
|
+
Token: wh_...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Claude Code:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude mcp add --transport http webhound https://api.webhound.ai/api/v2/mcp --header "Authorization: Bearer wh_..."
|
|
49
|
+
|
|
50
|
+
# Local stdio alternative:
|
|
51
|
+
claude mcp add --transport stdio webhound --env WEBHOUND_KEY=wh_... -- npx -y webhound-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Codex:
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
[mcp_servers.webhound]
|
|
58
|
+
command = "npx"
|
|
59
|
+
args = ["-y", "webhound-mcp"]
|
|
60
|
+
|
|
61
|
+
[mcp_servers.webhound.env]
|
|
62
|
+
WEBHOUND_KEY = "wh_..."
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Cursor and Claude Desktop use the JSON shape above.
|
|
66
|
+
|
|
67
|
+
After saving local stdio config, restart the agent session or open a new one
|
|
68
|
+
if the Webhound tools do not appear. Many clients load MCP servers only when a
|
|
69
|
+
session starts.
|
|
70
|
+
|
|
71
|
+
VS Code:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"servers": {
|
|
76
|
+
"webhound": {
|
|
77
|
+
"type": "stdio",
|
|
78
|
+
"command": "npx",
|
|
79
|
+
"args": ["-y", "webhound-mcp"],
|
|
80
|
+
"env": {
|
|
81
|
+
"WEBHOUND_KEY": "wh_..."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Use the same stdio server shape for Windsurf, Cline, and Roo Code. Windsurf commonly stores it in `~/.codeium/windsurf/mcp_config.json`; Roo Code supports global MCP settings or project-level `.roo/mcp.json`.
|
|
89
|
+
|
|
90
|
+
## Defaults
|
|
91
|
+
|
|
92
|
+
Recommended setup defaults:
|
|
93
|
+
|
|
94
|
+
- model: `flash`
|
|
95
|
+
- budget: `$5`
|
|
96
|
+
- product: `report`
|
|
97
|
+
- free run: enabled when available
|
|
98
|
+
|
|
99
|
+
New users may have one non-divisible free run pass. It covers one exact `$5` Flash report or dataset. It can be used from the Webhound UI, API, hosted MCP, or this stdio MCP package.
|
|
100
|
+
|
|
101
|
+
Agents can read and update defaults with:
|
|
102
|
+
|
|
103
|
+
- `webhound_get_defaults`
|
|
104
|
+
- `webhound_set_defaults`
|
|
105
|
+
|
|
106
|
+
## Tool Flow
|
|
107
|
+
|
|
108
|
+
The core lifecycle is detached and visible:
|
|
109
|
+
|
|
110
|
+
1. Start work with `webhound_start_report` or `webhound_start_dataset`.
|
|
111
|
+
2. Watch with `webhound_watch` or `webhound_wait`.
|
|
112
|
+
3. Treat `done=true` as the authoritative finished signal.
|
|
113
|
+
4. If `awaiting_input`, steer with `webhound_send_message`.
|
|
114
|
+
5. When `output_ready=true`, read with `webhound_get_output` or download an artifact with `webhound_export_session`.
|
|
115
|
+
6. Inspect provenance with `webhound_get_claims` and `webhound_get_sources`.
|
|
116
|
+
|
|
117
|
+
Budget controls depth. A healthy run may keep searching, reading, writing, and
|
|
118
|
+
verifying through several waits while it uses the budget. More budget means more
|
|
119
|
+
room for research before final assembly; it is not a signal for the calling
|
|
120
|
+
agent to hurry the run.
|
|
121
|
+
|
|
122
|
+
## Public Tools
|
|
123
|
+
|
|
124
|
+
- `webhound_health`
|
|
125
|
+
- `webhound_get_defaults`
|
|
126
|
+
- `webhound_set_defaults`
|
|
127
|
+
- `webhound_start_report`
|
|
128
|
+
- `webhound_start_dataset`
|
|
129
|
+
- `webhound_watch`
|
|
130
|
+
- `webhound_wait`
|
|
131
|
+
- `webhound_send_message`
|
|
132
|
+
- `webhound_stop`
|
|
133
|
+
- `webhound_resume`
|
|
134
|
+
- `webhound_add_budget`
|
|
135
|
+
- `webhound_get_output`
|
|
136
|
+
- `webhound_export_session`
|
|
137
|
+
- `webhound_get_claims`
|
|
138
|
+
- `webhound_get_sources`
|
|
139
|
+
- `webhound_search_sessions`
|
|
140
|
+
- `webhound_list_sessions`
|
|
141
|
+
- `webhound_get_session`
|
|
142
|
+
- `webhound_upload_file`
|
|
143
|
+
- `webhound_account`
|
|
144
|
+
- `webhound_diagnose`
|
|
145
|
+
|
|
146
|
+
## Completion And Diagnostics
|
|
147
|
+
|
|
148
|
+
`webhound_watch` returns:
|
|
149
|
+
|
|
150
|
+
- `done`: terminal status
|
|
151
|
+
- `output_ready`: safe to read and summarize
|
|
152
|
+
- `completion_reason`: `budget_complete`, `natural_complete`, `awaiting_input`, `user_stopped`, `credit_exhausted`, `failed`, or `stuck_or_empty`
|
|
153
|
+
- `alerts`: structured issues with next actions
|
|
154
|
+
|
|
155
|
+
Do not present a run as successful if `alerts` contains an error such as `empty_output`, `dataset_zero_rows`, or `credit_exhausted`.
|
|
156
|
+
|
|
157
|
+
If `webhound_wait` returns `still_running=true`, wait again. Use
|
|
158
|
+
`webhound_send_message` for user intent changes or `awaiting_input`, not for
|
|
159
|
+
normal elapsed time.
|
|
160
|
+
|
|
161
|
+
## CLI
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
webhound-mcp --help
|
|
165
|
+
webhound-mcp --version
|
|
166
|
+
webhound-mcp --self-test
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`--self-test` checks that the package loads and that the launch tool list is present. Use `webhound_health` from an MCP client to verify live auth and account state.
|
|
170
|
+
|
|
171
|
+
## Local Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
cd webhound-server/mcp
|
|
175
|
+
npm install
|
|
176
|
+
WEBHOUND_KEY=wh_... WEBHOUND_API_BASE=http://localhost:5000/api/v2 node bin/server.mjs
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
No npm publish is performed by this repo change. Publish only after the production server and docs are approved.
|
package/bin/server.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { createWebhoundMcpServer, TOOL_NAMES, VERSION } from '../core/server.mjs';
|
|
4
|
+
|
|
5
|
+
const args = new Set(process.argv.slice(2));
|
|
6
|
+
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`webhound-mcp ${VERSION}
|
|
9
|
+
|
|
10
|
+
Run Webhound's MCP server over stdio.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
WEBHOUND_KEY=wh_... npx -y webhound-mcp
|
|
14
|
+
webhound-mcp --help
|
|
15
|
+
webhound-mcp --version
|
|
16
|
+
webhound-mcp --self-test
|
|
17
|
+
|
|
18
|
+
Environment:
|
|
19
|
+
WEBHOUND_KEY Webhound API key (required for real tool calls)
|
|
20
|
+
WEBHOUND_API_BASE API base (default https://api.webhound.ai/api/v2)
|
|
21
|
+
WEBHOUND_APP_BASE App base (default https://webhound.ai)
|
|
22
|
+
WEBHOUND_DEFAULT_MODEL Optional local setup hint; server defaults still win
|
|
23
|
+
WEBHOUND_DEFAULT_BUDGET Optional local setup hint; server defaults still win
|
|
24
|
+
|
|
25
|
+
Public tools:
|
|
26
|
+
${TOOL_NAMES.join('\n ')}
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runSelfTest() {
|
|
31
|
+
const server = createWebhoundMcpServer({
|
|
32
|
+
apiKey: process.env.WEBHOUND_KEY || '',
|
|
33
|
+
apiBase: process.env.WEBHOUND_API_BASE,
|
|
34
|
+
appBase: process.env.WEBHOUND_APP_BASE,
|
|
35
|
+
});
|
|
36
|
+
const summary = {
|
|
37
|
+
ok: true,
|
|
38
|
+
version: VERSION,
|
|
39
|
+
tool_count: TOOL_NAMES.length,
|
|
40
|
+
required_tools_present: TOOL_NAMES,
|
|
41
|
+
has_key: !!process.env.WEBHOUND_KEY,
|
|
42
|
+
note: process.env.WEBHOUND_KEY
|
|
43
|
+
? 'Server factory loaded. Use an MCP client health call for live auth verification.'
|
|
44
|
+
: 'Server factory loaded. Set WEBHOUND_KEY to verify live auth.',
|
|
45
|
+
};
|
|
46
|
+
await server.close().catch(() => {});
|
|
47
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (args.has('--help') || args.has('-h')) {
|
|
51
|
+
printHelp();
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (args.has('--version') || args.has('-v')) {
|
|
56
|
+
console.log(VERSION);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (args.has('--self-test')) {
|
|
61
|
+
await runSelfTest();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const server = createWebhoundMcpServer({
|
|
66
|
+
apiKey: process.env.WEBHOUND_KEY || '',
|
|
67
|
+
apiBase: process.env.WEBHOUND_API_BASE,
|
|
68
|
+
appBase: process.env.WEBHOUND_APP_BASE,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const transport = new StdioServerTransport();
|
|
72
|
+
await server.connect(transport);
|
package/core/http.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
import { createWebhoundMcpServer } from './server.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleMcpHttpRequest(req, res, options = {}) {
|
|
5
|
+
const server = createWebhoundMcpServer(options);
|
|
6
|
+
const transport = new StreamableHTTPServerTransport({
|
|
7
|
+
sessionIdGenerator: undefined,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
await server.connect(transport);
|
|
12
|
+
await transport.handleRequest(req, res, req.body);
|
|
13
|
+
res.on('close', () => {
|
|
14
|
+
transport.close().catch(() => {});
|
|
15
|
+
server.close().catch(() => {});
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
await transport.close().catch(() => {});
|
|
19
|
+
await server.close().catch(() => {});
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/core/server.mjs
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { WebhoundApiClient, stripHtml } from './webhoundClient.mjs';
|
|
4
|
+
|
|
5
|
+
export const VERSION = '0.2.1';
|
|
6
|
+
|
|
7
|
+
export const TOOL_NAMES = Object.freeze([
|
|
8
|
+
'webhound_health',
|
|
9
|
+
'webhound_get_defaults',
|
|
10
|
+
'webhound_set_defaults',
|
|
11
|
+
'webhound_start_report',
|
|
12
|
+
'webhound_start_dataset',
|
|
13
|
+
'webhound_watch',
|
|
14
|
+
'webhound_wait',
|
|
15
|
+
'webhound_send_message',
|
|
16
|
+
'webhound_stop',
|
|
17
|
+
'webhound_resume',
|
|
18
|
+
'webhound_add_budget',
|
|
19
|
+
'webhound_get_output',
|
|
20
|
+
'webhound_export_session',
|
|
21
|
+
'webhound_get_claims',
|
|
22
|
+
'webhound_get_sources',
|
|
23
|
+
'webhound_search_sessions',
|
|
24
|
+
'webhound_list_sessions',
|
|
25
|
+
'webhound_get_session',
|
|
26
|
+
'webhound_upload_file',
|
|
27
|
+
'webhound_account',
|
|
28
|
+
'webhound_diagnose',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const SYSTEM_INSTRUCTIONS = `Webhound runs long research and dataset jobs for agents.
|
|
32
|
+
|
|
33
|
+
Use Webhound when the user wants fresh, cited research, market mapping, vendor lists, competitive scans, due diligence, or structured extraction from the web. Do not use it for a one-fact lookup.
|
|
34
|
+
|
|
35
|
+
Webhound is budgeted research. The budget is the research allowance Webhound uses for depth: a larger budget means it can keep searching, reading, writing, and verifying longer before assembly. The caller watches the job; Webhound decides when the budget is spent enough or the work is naturally complete.
|
|
36
|
+
|
|
37
|
+
The normal loop is:
|
|
38
|
+
1. Start a private report with webhound_start_report or a private dataset with webhound_start_dataset.
|
|
39
|
+
2. Watch with webhound_watch or webhound_wait. The authoritative done signal is done=true, not spend and not partial text existing.
|
|
40
|
+
3. If watch/wait says the run is still running and there are no blocking alerts, do nothing except keep waiting. Long research runs commonly take several wait cycles.
|
|
41
|
+
4. Use webhound_send_message only when the user gives new guidance or Webhound is explicitly awaiting_input. Steering changes the research objective; it is not a response to normal elapsed time.
|
|
42
|
+
5. When done and output_ready=true, read with webhound_get_output or export with webhound_export_session, then use webhound_get_claims and webhound_get_sources for provenance.
|
|
43
|
+
|
|
44
|
+
Defaults exist so agents do not waste user time asking about model and budget. The recommended default is model=flash, budget=$5, use_free_run_when_available=true. Reports and datasets may use a user's one free $5 Flash run when available.
|
|
45
|
+
|
|
46
|
+
If you are helping a user install local stdio MCP, tell them to restart the agent session or open a new one after saving config if Webhound tools do not appear. Many clients load MCP servers only when a session starts.
|
|
47
|
+
|
|
48
|
+
If webhound_watch returns alerts, explain them plainly and follow next_actions. A credit_exhausted alert means the account needs credits before retrying. An awaiting_input alert means ask the user for the missing guidance or pass along guidance the user already gave. An empty_output or dataset_zero_rows alert means do not present the run as successful.`;
|
|
49
|
+
|
|
50
|
+
const GUIDE = `# Webhound MCP Guide
|
|
51
|
+
|
|
52
|
+
Start long-running Webhound work, then watch it until done=true. Use defaults unless the user gives a different budget or model.
|
|
53
|
+
|
|
54
|
+
Recommended first run:
|
|
55
|
+
- product: report or dataset
|
|
56
|
+
- model: flash
|
|
57
|
+
- budget: $5
|
|
58
|
+
- free run: enabled when available
|
|
59
|
+
|
|
60
|
+
Budget model:
|
|
61
|
+
- The budget buys research depth, not a fixed answer length.
|
|
62
|
+
- A healthy run may keep working through several wait cycles while it uses the budget.
|
|
63
|
+
- More budget means more room for source discovery, reading, writing, and verification.
|
|
64
|
+
|
|
65
|
+
Local stdio setup:
|
|
66
|
+
- After saving config, restart the agent session or open a new one if Webhound tools do not appear.
|
|
67
|
+
- Many agents only load MCP servers when a session starts.
|
|
68
|
+
|
|
69
|
+
Completion:
|
|
70
|
+
- done=true is authoritative.
|
|
71
|
+
- output_ready=true means it is safe to read and summarize.
|
|
72
|
+
- webhound_export_session can export reports as Markdown, HTML, TXT, JSON traces, or PDF, and datasets as CSV, JSON, JSONL, Markdown, or PDF.
|
|
73
|
+
- completion_reason explains why the run stopped.
|
|
74
|
+
- If webhound_wait times out with still_running=true, that is normal. Wait again.
|
|
75
|
+
- Use webhound_send_message for user intent changes or awaiting_input, not because a healthy run is taking time.
|
|
76
|
+
|
|
77
|
+
Troubleshooting:
|
|
78
|
+
- credit_exhausted: top up or enable auto-recharge, then resume.
|
|
79
|
+
- awaiting_input: ask the user for the requested guidance, or send guidance the user already provided with webhound_send_message.
|
|
80
|
+
- empty_output or dataset_zero_rows: do not call it successful; inspect diagnostics and resume or rerun.
|
|
81
|
+
- weak_provenance: read sources/claims before sharing.`;
|
|
82
|
+
|
|
83
|
+
const PRICING = `# Webhound MCP Defaults And Spend
|
|
84
|
+
|
|
85
|
+
Recommended default: $5 Flash.
|
|
86
|
+
|
|
87
|
+
New users may have one free run pass:
|
|
88
|
+
- one private report or dataset
|
|
89
|
+
- exactly $5
|
|
90
|
+
- model flash
|
|
91
|
+
- not divisible into smaller credits
|
|
92
|
+
- usable through UI, API, hosted MCP, or stdio MCP
|
|
93
|
+
|
|
94
|
+
Tools that start or extend work spend credits or consume the pass:
|
|
95
|
+
- webhound_start_report
|
|
96
|
+
- webhound_start_dataset
|
|
97
|
+
- webhound_add_budget
|
|
98
|
+
- webhound_resume with additional_budget
|
|
99
|
+
|
|
100
|
+
Read/watch/search/account tools do not start new spend.`;
|
|
101
|
+
|
|
102
|
+
function jsonResult(summary, data, isError = false) {
|
|
103
|
+
return {
|
|
104
|
+
content: [
|
|
105
|
+
{ type: 'text', text: summary },
|
|
106
|
+
{ type: 'text', text: JSON.stringify(data, null, 2) },
|
|
107
|
+
],
|
|
108
|
+
structuredContent: data,
|
|
109
|
+
isError,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function errorResult(error, fallback = 'Webhound MCP tool failed') {
|
|
114
|
+
const status = error?.status || error?.body?.status || null;
|
|
115
|
+
const data = {
|
|
116
|
+
error: error?.body?.error || error?.code || 'webhound_error',
|
|
117
|
+
message: error?.message || fallback,
|
|
118
|
+
status,
|
|
119
|
+
body: error?.body || null,
|
|
120
|
+
next_actions: status === 402
|
|
121
|
+
? ['Tell the user to add credits or use an available free-run pass before retrying.']
|
|
122
|
+
: ['Inspect the error and retry only after the cause is fixed.'],
|
|
123
|
+
};
|
|
124
|
+
return jsonResult(`${fallback}: ${data.message}`, data, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function describeStarted(kind, client, result) {
|
|
128
|
+
const sessionId = result.session_id;
|
|
129
|
+
const freeRun = result.free_run?.reserved ? ' Free-run pass reserved.' : '';
|
|
130
|
+
return `${kind} started: ${sessionId}\nOpen: ${client.webUrl(sessionId)}\nNext: call webhound_watch with this session_id until done=true.${freeRun}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function registerTool(server, name, config, handler) {
|
|
134
|
+
server.registerTool(name, config, async (args) => {
|
|
135
|
+
try {
|
|
136
|
+
return await handler(args || {});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return errorResult(error, `${name} failed`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createWebhoundMcpServer(options = {}) {
|
|
144
|
+
const client = options.client || new WebhoundApiClient(options);
|
|
145
|
+
const server = new McpServer({
|
|
146
|
+
name: 'webhound',
|
|
147
|
+
version: VERSION,
|
|
148
|
+
instructions: SYSTEM_INSTRUCTIONS,
|
|
149
|
+
websiteUrl: 'https://webhound.ai',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.registerResource('webhound_guide', 'webhound://guide', {
|
|
153
|
+
title: 'Webhound MCP Guide',
|
|
154
|
+
description: 'How agents should use Webhound MCP.',
|
|
155
|
+
mimeType: 'text/markdown',
|
|
156
|
+
}, async () => ({ contents: [{ uri: 'webhound://guide', mimeType: 'text/markdown', text: GUIDE }] }));
|
|
157
|
+
|
|
158
|
+
server.registerResource('webhound_pricing', 'webhound://pricing', {
|
|
159
|
+
title: 'Webhound MCP Pricing',
|
|
160
|
+
description: 'Default budgets, free-run pass, and spend-bearing tools.',
|
|
161
|
+
mimeType: 'text/markdown',
|
|
162
|
+
}, async () => ({ contents: [{ uri: 'webhound://pricing', mimeType: 'text/markdown', text: PRICING }] }));
|
|
163
|
+
|
|
164
|
+
server.registerResource('webhound_session_status', new ResourceTemplate('webhound://session/{sessionId}/status', { list: undefined }), {
|
|
165
|
+
title: 'Webhound Session Status',
|
|
166
|
+
description: 'Live diagnostics for a Webhound session.',
|
|
167
|
+
mimeType: 'application/json',
|
|
168
|
+
}, async (uri, variables) => {
|
|
169
|
+
const data = await client.watch(variables.sessionId);
|
|
170
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data, null, 2) }] };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.registerPrompt('webhound_report_brief', {
|
|
174
|
+
title: 'Start a Webhound report',
|
|
175
|
+
description: 'Prompt template for running a cited Webhound report.',
|
|
176
|
+
argsSchema: { question: z.string(), budget: z.string().optional() },
|
|
177
|
+
}, async ({ question, budget }) => ({
|
|
178
|
+
messages: [{
|
|
179
|
+
role: 'user',
|
|
180
|
+
content: { type: 'text', text: `Use Webhound to run a ${budget || '$5 Flash'} report on:\n\n${question}\n\nWebhound uses the budget for research depth, so a healthy run may keep working through several waits. Watch until done=true, then summarize the output, sources, claim trace health, and any alerts.` },
|
|
181
|
+
}],
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
server.registerPrompt('webhound_dataset_brief', {
|
|
185
|
+
title: 'Start a Webhound dataset',
|
|
186
|
+
description: 'Prompt template for extracting a sourced dataset.',
|
|
187
|
+
argsSchema: { task: z.string(), schema: z.string().optional(), budget: z.string().optional() },
|
|
188
|
+
}, async ({ task, schema, budget }) => ({
|
|
189
|
+
messages: [{
|
|
190
|
+
role: 'user',
|
|
191
|
+
content: { type: 'text', text: `Use Webhound to run a ${budget || '$5 Flash'} dataset extraction.\n\nTask:\n${task}\n\nSchema:\n${schema || 'Infer a concise schema if I did not provide one.'}\n\nWebhound uses the budget for extraction depth, so a healthy run may keep working through several waits. Watch until done=true, then report rows, fill rate, source coverage, and alerts.` },
|
|
192
|
+
}],
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
server.registerPrompt('webhound_troubleshoot_session', {
|
|
196
|
+
title: 'Troubleshoot a Webhound session',
|
|
197
|
+
description: 'Prompt template for diagnosing a session that looks wrong.',
|
|
198
|
+
argsSchema: { session_id: z.string() },
|
|
199
|
+
}, async ({ session_id }) => ({
|
|
200
|
+
messages: [{
|
|
201
|
+
role: 'user',
|
|
202
|
+
content: { type: 'text', text: `Use webhound_diagnose, webhound_watch, and relevant output/source tools to explain what happened in session ${session_id}. Be direct about whether it is usable.` },
|
|
203
|
+
}],
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
registerTool(server, 'webhound_health', {
|
|
207
|
+
title: 'Webhound Health',
|
|
208
|
+
description: 'No-spend health check: auth, API status, credits, free-run pass, defaults, and MCP version.',
|
|
209
|
+
inputSchema: {},
|
|
210
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
211
|
+
}, async () => {
|
|
212
|
+
const data = await client.health();
|
|
213
|
+
data.mcp = { version: VERSION, tools: TOOL_NAMES };
|
|
214
|
+
return jsonResult(data.authenticated ? 'Webhound MCP is connected.' : 'Webhound MCP is not authenticated.', data, !data.authenticated);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
registerTool(server, 'webhound_get_defaults', {
|
|
218
|
+
title: 'Get Webhound MCP Defaults',
|
|
219
|
+
description: 'Read the saved MCP defaults for model, budget, product, and free-run use.',
|
|
220
|
+
inputSchema: {},
|
|
221
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
222
|
+
}, async () => jsonResult('Current Webhound MCP defaults.', await client.getDefaults()));
|
|
223
|
+
|
|
224
|
+
registerTool(server, 'webhound_set_defaults', {
|
|
225
|
+
title: 'Set Webhound MCP Defaults',
|
|
226
|
+
description: 'Set default model/budget/product for future MCP runs. Recommended: flash, $5, use free run.',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
default_model: z.enum(['flash', 'pro', 'auto']).default('flash'),
|
|
229
|
+
default_budget_usd: z.number().min(1).max(500).default(5),
|
|
230
|
+
default_product: z.enum(['report', 'dataset']).default('report'),
|
|
231
|
+
use_free_run_when_available: z.boolean().default(true),
|
|
232
|
+
},
|
|
233
|
+
}, async (args) => jsonResult('Webhound MCP defaults saved.', await client.setDefaults(args)));
|
|
234
|
+
|
|
235
|
+
registerTool(server, 'webhound_start_report', {
|
|
236
|
+
title: 'Start Webhound Report',
|
|
237
|
+
description: 'Start a private long-running Webhound report. Budget controls research depth; watch until done=true.',
|
|
238
|
+
inputSchema: {
|
|
239
|
+
prompt: z.string().min(8).max(12000),
|
|
240
|
+
budget: z.number().min(1).max(500).optional(),
|
|
241
|
+
model: z.enum(['flash', 'pro', 'auto']).optional(),
|
|
242
|
+
title: z.string().optional(),
|
|
243
|
+
max_mode: z.boolean().optional(),
|
|
244
|
+
output_instructions: z.string().optional(),
|
|
245
|
+
context_session_ids: z.array(z.string()).optional(),
|
|
246
|
+
file_ids: z.array(z.string()).optional(),
|
|
247
|
+
enable_checkpoints: z.boolean().optional(),
|
|
248
|
+
use_free_run_when_available: z.boolean().optional(),
|
|
249
|
+
},
|
|
250
|
+
}, async (args) => {
|
|
251
|
+
const data = await client.startReport(args);
|
|
252
|
+
return jsonResult(describeStarted('Report', client, data), { ...data, url: client.webUrl(data.session_id), next_tool: 'webhound_watch' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
registerTool(server, 'webhound_start_dataset', {
|
|
256
|
+
title: 'Start Webhound Dataset',
|
|
257
|
+
description: 'Start a private long-running Webhound dataset extraction. Budget controls extraction depth; watch until done=true.',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
prompt: z.string().min(8).max(12000),
|
|
260
|
+
schema: z.any().optional(),
|
|
261
|
+
budget: z.number().min(1).max(500).optional(),
|
|
262
|
+
model: z.enum(['flash', 'pro', 'auto']).optional(),
|
|
263
|
+
title: z.string().optional(),
|
|
264
|
+
max_mode: z.boolean().optional(),
|
|
265
|
+
context_session_ids: z.array(z.string()).optional(),
|
|
266
|
+
file_ids: z.array(z.string()).optional(),
|
|
267
|
+
enable_checkpoints: z.boolean().optional(),
|
|
268
|
+
use_free_run_when_available: z.boolean().optional(),
|
|
269
|
+
},
|
|
270
|
+
}, async (args) => {
|
|
271
|
+
const data = await client.startDataset(args);
|
|
272
|
+
return jsonResult(describeStarted('Dataset', client, data), { ...data, url: client.webUrl(data.session_id), next_tool: 'webhound_watch' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
registerTool(server, 'webhound_watch', {
|
|
276
|
+
title: 'Watch Webhound Session',
|
|
277
|
+
description: 'Authoritative session watcher. done=true means the run is terminal; output_ready=true means it is safe to read.',
|
|
278
|
+
inputSchema: { session_id: z.string() },
|
|
279
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
280
|
+
}, async ({ session_id }) => {
|
|
281
|
+
const data = await client.watch(session_id);
|
|
282
|
+
const summary = data.done
|
|
283
|
+
? `Session ${session_id} is done: ${data.completion_reason || data.status}. output_ready=${!!data.output_ready}.`
|
|
284
|
+
: `Session ${session_id} is still running: ${data.status}. Keep waiting with webhound_watch or webhound_wait; Webhound is using the budget for more research unless it asks for input or returns an alert.`;
|
|
285
|
+
return jsonResult(summary, data, data.alerts?.some(alert => alert.severity === 'error') && data.done);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
registerTool(server, 'webhound_wait', {
|
|
289
|
+
title: 'Wait For Webhound Session',
|
|
290
|
+
description: 'Bounded wait wrapper around webhound_watch. Max 110 seconds, then returns still_running if not terminal. still_running is normal; call wait/watch again unless status is awaiting_input or a blocking alert is present.',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
session_id: z.string(),
|
|
293
|
+
max_wait_seconds: z.number().int().min(1).max(110).default(90),
|
|
294
|
+
poll_interval_seconds: z.number().int().min(3).max(30).default(10),
|
|
295
|
+
},
|
|
296
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
297
|
+
}, async ({ session_id, max_wait_seconds, poll_interval_seconds }) => {
|
|
298
|
+
const data = await client.wait(session_id, { maxWaitSeconds: max_wait_seconds, pollIntervalSeconds: poll_interval_seconds });
|
|
299
|
+
return jsonResult(data.done ? `Session ${session_id} is done.` : `Session ${session_id} is still running. Keep waiting; Webhound is using the budget for more research unless it asks for input or returns an alert.`, data);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
registerTool(server, 'webhound_send_message', {
|
|
303
|
+
title: 'Steer Webhound Session',
|
|
304
|
+
description: 'Send user-provided guidance to a session. Use this for intent changes or awaiting_input; normal running time means the budget is still being used for research.',
|
|
305
|
+
inputSchema: { session_id: z.string(), message: z.string().min(1).max(6000) },
|
|
306
|
+
}, async ({ session_id, message }) => jsonResult('Guidance sent to Webhound session.', await client.sendMessage(session_id, message)));
|
|
307
|
+
|
|
308
|
+
registerTool(server, 'webhound_stop', {
|
|
309
|
+
title: 'Stop Webhound Session',
|
|
310
|
+
description: 'Pause/stop a running Webhound report or dataset without deleting it.',
|
|
311
|
+
inputSchema: { session_id: z.string() },
|
|
312
|
+
}, async ({ session_id }) => jsonResult('Stop signal sent.', await client.stop(session_id)));
|
|
313
|
+
|
|
314
|
+
registerTool(server, 'webhound_resume', {
|
|
315
|
+
title: 'Resume Webhound Session',
|
|
316
|
+
description: 'Resume a paused/completed/awaiting-input session with optional additional budget and guidance.',
|
|
317
|
+
inputSchema: {
|
|
318
|
+
session_id: z.string(),
|
|
319
|
+
additional_budget: z.number().min(0).max(500).optional(),
|
|
320
|
+
guidance: z.string().optional(),
|
|
321
|
+
file_ids: z.array(z.string()).optional(),
|
|
322
|
+
context_session_ids: z.array(z.string()).optional(),
|
|
323
|
+
},
|
|
324
|
+
}, async ({ session_id, ...args }) => jsonResult('Session resume requested.', await client.resume(session_id, args)));
|
|
325
|
+
|
|
326
|
+
registerTool(server, 'webhound_add_budget', {
|
|
327
|
+
title: 'Add Webhound Budget',
|
|
328
|
+
description: 'Add research budget and optional guidance/context to a session.',
|
|
329
|
+
inputSchema: {
|
|
330
|
+
session_id: z.string(),
|
|
331
|
+
amount: z.number().min(1).max(500),
|
|
332
|
+
guidance: z.string().optional(),
|
|
333
|
+
file_ids: z.array(z.string()).optional(),
|
|
334
|
+
context_session_ids: z.array(z.string()).optional(),
|
|
335
|
+
},
|
|
336
|
+
}, async ({ session_id, ...args }) => jsonResult('Budget added to Webhound session.', await client.addBudget(session_id, args)));
|
|
337
|
+
|
|
338
|
+
registerTool(server, 'webhound_get_output', {
|
|
339
|
+
title: 'Get Webhound Output',
|
|
340
|
+
description: 'Read final report/working document or dataset rows.',
|
|
341
|
+
inputSchema: {
|
|
342
|
+
session_id: z.string(),
|
|
343
|
+
kind: z.enum(['auto', 'report', 'dataset']).default('auto'),
|
|
344
|
+
doc_name: z.string().optional(),
|
|
345
|
+
select: z.enum(['output', 'working', 'latest']).optional(),
|
|
346
|
+
},
|
|
347
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
348
|
+
}, async ({ session_id, ...args }) => {
|
|
349
|
+
const data = await client.getOutput(session_id, args);
|
|
350
|
+
const readable = data.content_markdown || data.content || (data.rows ? `${data.total_rows || data.rows.length} rows` : JSON.stringify(data).slice(0, 1000));
|
|
351
|
+
return jsonResult(`Output for ${session_id}:\n\n${stripHtml(readable).slice(0, 4000)}`, data);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
registerTool(server, 'webhound_export_session', {
|
|
355
|
+
title: 'Export Webhound Session',
|
|
356
|
+
description: 'Export a completed report or dataset as Markdown, HTML, TXT, JSON traces, CSV, JSONL, or PDF. Does not spend credits.',
|
|
357
|
+
inputSchema: {
|
|
358
|
+
session_id: z.string(),
|
|
359
|
+
format: z.enum(['auto', 'md', 'markdown', 'html', 'txt', 'text', 'json', 'json_traces', 'csv', 'jsonl', 'pdf']).default('auto'),
|
|
360
|
+
select: z.enum(['output', 'working', 'latest', 'all']).default('output'),
|
|
361
|
+
doc_name: z.string().optional(),
|
|
362
|
+
include_content: z.boolean().default(true),
|
|
363
|
+
max_chars: z.number().int().min(1000).max(200000).default(60000),
|
|
364
|
+
},
|
|
365
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
366
|
+
}, async ({ session_id, include_content, max_chars, ...args }) => {
|
|
367
|
+
const data = await client.exportSession(session_id, args);
|
|
368
|
+
const content = data.content || '';
|
|
369
|
+
const isBase64 = data.encoding === 'base64';
|
|
370
|
+
const capped = include_content && !isBase64 && typeof content === 'string' && content.length > max_chars;
|
|
371
|
+
const base64Omitted = isBase64 && (!include_content || String(content).length > max_chars);
|
|
372
|
+
const structured = {
|
|
373
|
+
...data,
|
|
374
|
+
content: include_content && !isBase64 ? String(content).slice(0, max_chars) : undefined,
|
|
375
|
+
content_truncated: !!capped || base64Omitted,
|
|
376
|
+
content_base64: include_content && isBase64 && !base64Omitted ? content : undefined,
|
|
377
|
+
content_base64_omitted: base64Omitted || undefined,
|
|
378
|
+
};
|
|
379
|
+
const summary = `Exported ${session_id} as ${data.filename} (${data.mime_type}, ${data.size_bytes} bytes).`;
|
|
380
|
+
return jsonResult((capped || base64Omitted) ? `${summary} Content was omitted or truncated; use download_url for the full file.` : summary, structured);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
registerTool(server, 'webhound_get_claims', {
|
|
384
|
+
title: 'Get Webhound Claims',
|
|
385
|
+
description: 'Read normalized claim traces and provenance for a session.',
|
|
386
|
+
inputSchema: { session_id: z.string() },
|
|
387
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
388
|
+
}, async ({ session_id }) => jsonResult('Claim traces for Webhound session.', await client.getClaims(session_id)));
|
|
389
|
+
|
|
390
|
+
registerTool(server, 'webhound_get_sources', {
|
|
391
|
+
title: 'Get Webhound Sources',
|
|
392
|
+
description: 'Read source inventory and citation counts for a session.',
|
|
393
|
+
inputSchema: { session_id: z.string() },
|
|
394
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
395
|
+
}, async ({ session_id }) => jsonResult('Sources for Webhound session.', await client.getSources(session_id)));
|
|
396
|
+
|
|
397
|
+
registerTool(server, 'webhound_search_sessions', {
|
|
398
|
+
title: 'Search Webhound Sessions',
|
|
399
|
+
description: 'Semantic search across prior Webhound sessions.',
|
|
400
|
+
inputSchema: { query: z.string().min(2), limit: z.number().int().min(1).max(50).default(10) },
|
|
401
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
402
|
+
}, async (args) => jsonResult(`Search results for "${args.query}".`, await client.searchSessions(args)));
|
|
403
|
+
|
|
404
|
+
registerTool(server, 'webhound_list_sessions', {
|
|
405
|
+
title: 'List Webhound Sessions',
|
|
406
|
+
description: 'List recent Webhound sessions.',
|
|
407
|
+
inputSchema: {
|
|
408
|
+
limit: z.number().int().min(1).max(50).default(15),
|
|
409
|
+
type: z.enum(['research', 'extraction', 'all']).default('all'),
|
|
410
|
+
status: z.string().optional(),
|
|
411
|
+
page: z.number().int().min(1).default(1),
|
|
412
|
+
},
|
|
413
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
414
|
+
}, async (args) => jsonResult('Recent Webhound sessions.', await client.listSessions(args)));
|
|
415
|
+
|
|
416
|
+
registerTool(server, 'webhound_get_session', {
|
|
417
|
+
title: 'Get Webhound Session',
|
|
418
|
+
description: 'Read overview plus diagnostics for one session.',
|
|
419
|
+
inputSchema: { session_id: z.string() },
|
|
420
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
421
|
+
}, async ({ session_id }) => jsonResult('Webhound session overview.', await client.getSession(session_id)));
|
|
422
|
+
|
|
423
|
+
registerTool(server, 'webhound_upload_file', {
|
|
424
|
+
title: 'Upload Webhound File',
|
|
425
|
+
description: 'Upload a local file, text, or base64 content for use in a report or dataset.',
|
|
426
|
+
inputSchema: {
|
|
427
|
+
local_path: z.string().optional(),
|
|
428
|
+
file_name: z.string().optional(),
|
|
429
|
+
text: z.string().optional(),
|
|
430
|
+
content_base64: z.string().optional(),
|
|
431
|
+
mime_type: z.string().optional(),
|
|
432
|
+
},
|
|
433
|
+
}, async (args) => jsonResult('File uploaded to Webhound.', await client.uploadFile(args)));
|
|
434
|
+
|
|
435
|
+
registerTool(server, 'webhound_account', {
|
|
436
|
+
title: 'Webhound Account',
|
|
437
|
+
description: 'Read credits, recent usage, free-run status, and defaults. Does not spend.',
|
|
438
|
+
inputSchema: {},
|
|
439
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
440
|
+
}, async () => jsonResult('Webhound account status.', await client.account()));
|
|
441
|
+
|
|
442
|
+
registerTool(server, 'webhound_diagnose', {
|
|
443
|
+
title: 'Diagnose Webhound Session',
|
|
444
|
+
description: 'Explain whether a session is healthy, done, usable, and what to do next. For a healthy running session, the correct next action is to keep waiting.',
|
|
445
|
+
inputSchema: { session_id: z.string() },
|
|
446
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
447
|
+
}, async ({ session_id }) => {
|
|
448
|
+
const data = await client.watch(session_id);
|
|
449
|
+
const errors = (data.alerts || []).filter(alert => alert.severity === 'error');
|
|
450
|
+
const summary = errors.length
|
|
451
|
+
? `Session ${session_id} has blocking issue(s): ${errors.map(item => item.code).join(', ')}.`
|
|
452
|
+
: data.done
|
|
453
|
+
? `Session ${session_id} diagnostics: ${data.completion_reason || data.status || 'unknown'}; output_ready=${!!data.output_ready}.`
|
|
454
|
+
: `Session ${session_id} is healthy and still running. Keep waiting; Webhound is using the budget for more research unless it asks for input or returns an alert.`;
|
|
455
|
+
return jsonResult(summary, data, errors.length > 0);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return server;
|
|
459
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_API_BASE = 'https://api.webhound.ai/api/v2';
|
|
5
|
+
const DEFAULT_APP_BASE = 'https://webhound.ai';
|
|
6
|
+
|
|
7
|
+
export function titleFromPrompt(prompt, prefix = '') {
|
|
8
|
+
const clean = String(prompt || '').replace(/\s+/g, ' ').trim();
|
|
9
|
+
const text = clean.length > 70 ? `${clean.slice(0, 67).trimEnd()}...` : clean;
|
|
10
|
+
return prefix ? `${prefix}${text}` : text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function stripHtml(html) {
|
|
14
|
+
return String(html || '')
|
|
15
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|blockquote)>/gi, '\n')
|
|
16
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
17
|
+
.replace(/<[^>]+>/g, '')
|
|
18
|
+
.replace(/ /g, ' ')
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, "'")
|
|
24
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sessionUrl(appBase, sessionId) {
|
|
29
|
+
return `${String(appBase || DEFAULT_APP_BASE).replace(/\/+$/, '')}/session/${sessionId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeApiBase(value) {
|
|
33
|
+
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mimeFromFilename(fileName) {
|
|
37
|
+
const ext = path.extname(fileName || '').toLowerCase();
|
|
38
|
+
if (ext === '.csv') return 'text/csv';
|
|
39
|
+
if (ext === '.txt') return 'text/plain';
|
|
40
|
+
if (ext === '.md') return 'text/markdown';
|
|
41
|
+
if (ext === '.json') return 'application/json';
|
|
42
|
+
if (ext === '.pdf') return 'application/pdf';
|
|
43
|
+
return 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class WebhoundApiClient {
|
|
47
|
+
constructor({ apiBase, appBase, apiKey }) {
|
|
48
|
+
this.apiBase = normalizeApiBase(apiBase);
|
|
49
|
+
this.appBase = String(appBase || DEFAULT_APP_BASE).replace(/\/+$/, '');
|
|
50
|
+
this.apiKey = apiKey || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
headers(extra = {}) {
|
|
54
|
+
const headers = { ...extra };
|
|
55
|
+
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
|
56
|
+
return headers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
requireKey() {
|
|
60
|
+
if (!this.apiKey) {
|
|
61
|
+
const error = new Error('WEBHOUND_KEY is not set. Create an API key in Webhound and set WEBHOUND_KEY, or use hosted MCP with Authorization: Bearer wh_...');
|
|
62
|
+
error.code = 'missing_key';
|
|
63
|
+
error.status = 401;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async request(method, endpoint, body, options = {}) {
|
|
69
|
+
this.requireKey();
|
|
70
|
+
const headers = this.headers(options.headers || {});
|
|
71
|
+
let requestBody;
|
|
72
|
+
if (body instanceof FormData) {
|
|
73
|
+
requestBody = body;
|
|
74
|
+
} else if (body !== undefined) {
|
|
75
|
+
headers['Content-Type'] = 'application/json';
|
|
76
|
+
requestBody = JSON.stringify(body);
|
|
77
|
+
}
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(`${this.apiBase}${endpoint}`, { method, headers, body: requestBody });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const wrapped = new Error(`Network error calling Webhound ${method} ${endpoint}: ${error?.message || error}`);
|
|
83
|
+
wrapped.code = 'network_error';
|
|
84
|
+
throw wrapped;
|
|
85
|
+
}
|
|
86
|
+
const text = await response.text().catch(() => '');
|
|
87
|
+
let json = {};
|
|
88
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const message = json?.error || json?.message || json?.raw || `HTTP ${response.status}`;
|
|
91
|
+
const error = new Error(`Webhound ${response.status}: ${message}`);
|
|
92
|
+
error.status = response.status;
|
|
93
|
+
error.body = json;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return json?.data !== undefined ? json.data : json;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get(endpoint) { return this.request('GET', endpoint); }
|
|
100
|
+
post(endpoint, body) { return this.request('POST', endpoint, body); }
|
|
101
|
+
patch(endpoint, body) { return this.request('PATCH', endpoint, body); }
|
|
102
|
+
|
|
103
|
+
webUrl(sessionId) {
|
|
104
|
+
return sessionUrl(this.appBase, sessionId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
apiUrl(endpoint) {
|
|
108
|
+
return `${this.apiBase}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async health() {
|
|
112
|
+
try {
|
|
113
|
+
const [health, credits, defaults, freeRun] = await Promise.all([
|
|
114
|
+
this.get('/health').catch(error => ({ error: error.message })),
|
|
115
|
+
this.get('/account/credits').catch(error => ({ error: error.message })),
|
|
116
|
+
this.get('/mcp/defaults').catch(error => ({ error: error.message })),
|
|
117
|
+
this.get('/mcp/free-run').catch(error => ({ error: error.message })),
|
|
118
|
+
]);
|
|
119
|
+
return { authenticated: true, health, credits, defaults: defaults?.defaults || defaults, free_run: freeRun?.free_run || freeRun };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { authenticated: false, error: error.message, code: error.code || null, status: error.status || null };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getDefaults() {
|
|
126
|
+
const data = await this.get('/mcp/defaults');
|
|
127
|
+
return data.defaults || data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async setDefaults(input) {
|
|
131
|
+
const data = await this.patch('/mcp/defaults', input);
|
|
132
|
+
return data.defaults || data;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async account() {
|
|
136
|
+
const [credits, usage, freeRun, defaults] = await Promise.all([
|
|
137
|
+
this.get('/account/credits'),
|
|
138
|
+
this.get('/account/usage?days=30'),
|
|
139
|
+
this.get('/mcp/free-run').catch(() => null),
|
|
140
|
+
this.get('/mcp/defaults').catch(() => null),
|
|
141
|
+
]);
|
|
142
|
+
return {
|
|
143
|
+
credits,
|
|
144
|
+
usage,
|
|
145
|
+
free_run: freeRun?.free_run || null,
|
|
146
|
+
defaults: defaults?.defaults || null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async startReport(args) {
|
|
151
|
+
const defaults = await this.getDefaults().catch(() => ({}));
|
|
152
|
+
const budget = Number(args.budget ?? defaults.default_budget_usd ?? 5);
|
|
153
|
+
const model = args.model || defaults.default_model || 'flash';
|
|
154
|
+
return this.post('/research', {
|
|
155
|
+
title: args.title || titleFromPrompt(args.prompt),
|
|
156
|
+
query: args.prompt,
|
|
157
|
+
budget,
|
|
158
|
+
model,
|
|
159
|
+
max_mode: !!args.max_mode,
|
|
160
|
+
output_instructions: args.output_instructions || undefined,
|
|
161
|
+
context_session_ids: args.context_session_ids || undefined,
|
|
162
|
+
file_ids: args.file_ids || undefined,
|
|
163
|
+
enable_checkpoints: args.enable_checkpoints,
|
|
164
|
+
use_free_run_when_available: args.use_free_run_when_available ?? defaults.use_free_run_when_available ?? true,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async startDataset(args) {
|
|
169
|
+
const defaults = await this.getDefaults().catch(() => ({}));
|
|
170
|
+
const budget = Number(args.budget ?? defaults.default_budget_usd ?? 5);
|
|
171
|
+
const model = args.model || defaults.default_model || 'flash';
|
|
172
|
+
return this.post('/extractions', {
|
|
173
|
+
title: args.title || titleFromPrompt(args.prompt),
|
|
174
|
+
query: args.prompt,
|
|
175
|
+
budget,
|
|
176
|
+
model,
|
|
177
|
+
max_mode: !!args.max_mode,
|
|
178
|
+
schema: args.schema || undefined,
|
|
179
|
+
context_session_ids: args.context_session_ids || undefined,
|
|
180
|
+
file_ids: args.file_ids || undefined,
|
|
181
|
+
enable_checkpoints: args.enable_checkpoints,
|
|
182
|
+
use_free_run_when_available: args.use_free_run_when_available ?? defaults.use_free_run_when_available ?? true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async watch(sessionId) {
|
|
187
|
+
const [status, diagnostics] = await Promise.all([
|
|
188
|
+
this.get(`/sessions/${encodeURIComponent(sessionId)}/status`).catch(error => ({ error: error.message })),
|
|
189
|
+
this.get(`/sessions/${encodeURIComponent(sessionId)}/diagnostics`).catch(error => ({ error: error.message })),
|
|
190
|
+
]);
|
|
191
|
+
return {
|
|
192
|
+
...(diagnostics || {}),
|
|
193
|
+
status_snapshot: status,
|
|
194
|
+
url: this.webUrl(sessionId),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async wait(sessionId, { maxWaitSeconds = 90, pollIntervalSeconds = 10 } = {}) {
|
|
199
|
+
const deadline = Date.now() + Math.min(Math.max(Number(maxWaitSeconds) || 90, 1), 110) * 1000;
|
|
200
|
+
const interval = Math.min(Math.max(Number(pollIntervalSeconds) || 10, 3), 30) * 1000;
|
|
201
|
+
const snapshots = [];
|
|
202
|
+
while (true) {
|
|
203
|
+
const snapshot = await this.watch(sessionId);
|
|
204
|
+
snapshots.push(snapshot);
|
|
205
|
+
if (snapshot.done) return { ...snapshot, snapshots };
|
|
206
|
+
if (Date.now() + interval > deadline) return { ...snapshot, snapshots, still_running: true };
|
|
207
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async sendMessage(sessionId, message) {
|
|
212
|
+
return this.post(`/sessions/${encodeURIComponent(sessionId)}/messages`, { message });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async stop(sessionId) {
|
|
216
|
+
return this.post(`/research/${encodeURIComponent(sessionId)}/stop`, {});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async resume(sessionId, args = {}) {
|
|
220
|
+
return this.post(`/research/${encodeURIComponent(sessionId)}/resume`, {
|
|
221
|
+
additional_budget: args.additional_budget,
|
|
222
|
+
guidance: args.guidance,
|
|
223
|
+
file_ids: args.file_ids,
|
|
224
|
+
context_session_ids: args.context_session_ids,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async addBudget(sessionId, args = {}) {
|
|
229
|
+
return this.post(`/research/${encodeURIComponent(sessionId)}/budget`, {
|
|
230
|
+
amount: args.amount,
|
|
231
|
+
guidance: args.guidance,
|
|
232
|
+
file_ids: args.file_ids,
|
|
233
|
+
context_session_ids: args.context_session_ids,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async getOutput(sessionId, args = {}) {
|
|
238
|
+
const kind = args.kind || 'auto';
|
|
239
|
+
if (kind === 'dataset') {
|
|
240
|
+
return this.get(`/sessions/${encodeURIComponent(sessionId)}/dataset`);
|
|
241
|
+
}
|
|
242
|
+
if (kind === 'auto') {
|
|
243
|
+
const overview = await this.get(`/sessions/${encodeURIComponent(sessionId)}`).catch(() => null);
|
|
244
|
+
if (overview?.session_type === 'extraction') return this.get(`/sessions/${encodeURIComponent(sessionId)}/dataset`);
|
|
245
|
+
}
|
|
246
|
+
const params = new URLSearchParams();
|
|
247
|
+
if (args.doc_name) params.set('doc_name', args.doc_name);
|
|
248
|
+
if (args.select) params.set('select', args.select);
|
|
249
|
+
const data = await this.get(`/sessions/${encodeURIComponent(sessionId)}/document${params.toString() ? `?${params}` : ''}`);
|
|
250
|
+
return { ...data, content_markdown: stripHtml(data.content || '') };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async exportSession(sessionId, args = {}) {
|
|
254
|
+
const params = new URLSearchParams();
|
|
255
|
+
if (args.format) params.set('format', args.format);
|
|
256
|
+
if (args.doc_name) params.set('doc_name', args.doc_name);
|
|
257
|
+
if (args.select) params.set('select', args.select);
|
|
258
|
+
const endpoint = `/sessions/${encodeURIComponent(sessionId)}/export${params.toString() ? `?${params}` : ''}`;
|
|
259
|
+
const data = await this.get(endpoint);
|
|
260
|
+
const downloadParams = new URLSearchParams(params);
|
|
261
|
+
downloadParams.set('download', 'true');
|
|
262
|
+
const downloadEndpoint = `/sessions/${encodeURIComponent(sessionId)}/export?${downloadParams}`;
|
|
263
|
+
return {
|
|
264
|
+
...data,
|
|
265
|
+
download_url: this.apiUrl(downloadEndpoint),
|
|
266
|
+
download_note: 'Use this URL with Authorization: Bearer wh_... to download the artifact directly.',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async getClaims(sessionId) {
|
|
271
|
+
return this.get(`/sessions/${encodeURIComponent(sessionId)}/claims`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async getSources(sessionId) {
|
|
275
|
+
return this.get(`/sessions/${encodeURIComponent(sessionId)}/sources`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async listSessions(args = {}) {
|
|
279
|
+
const params = new URLSearchParams({
|
|
280
|
+
page: String(args.page || 1),
|
|
281
|
+
page_size: String(args.limit || 15),
|
|
282
|
+
});
|
|
283
|
+
if (args.type && args.type !== 'all') params.set('session_type', args.type);
|
|
284
|
+
if (args.status) params.set('status', args.status);
|
|
285
|
+
return this.get(`/sessions?${params}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async searchSessions(args = {}) {
|
|
289
|
+
const params = new URLSearchParams({
|
|
290
|
+
query: args.query,
|
|
291
|
+
limit: String(args.limit || 10),
|
|
292
|
+
});
|
|
293
|
+
return this.get(`/sessions/search?${params}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async getSession(sessionId) {
|
|
297
|
+
const [overview, watch] = await Promise.all([
|
|
298
|
+
this.get(`/sessions/${encodeURIComponent(sessionId)}`),
|
|
299
|
+
this.watch(sessionId),
|
|
300
|
+
]);
|
|
301
|
+
return { ...overview, diagnostics: watch, url: this.webUrl(sessionId) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async uploadFile(args = {}) {
|
|
305
|
+
const form = new FormData();
|
|
306
|
+
const fileName = args.file_name || (args.local_path ? path.basename(args.local_path) : 'webhound-input.txt');
|
|
307
|
+
let bytes;
|
|
308
|
+
if (args.local_path) {
|
|
309
|
+
bytes = await fs.readFile(args.local_path);
|
|
310
|
+
} else if (args.content_base64) {
|
|
311
|
+
bytes = Buffer.from(args.content_base64, 'base64');
|
|
312
|
+
} else if (args.text !== undefined) {
|
|
313
|
+
bytes = Buffer.from(String(args.text), 'utf8');
|
|
314
|
+
} else {
|
|
315
|
+
throw new Error('Provide local_path, content_base64, or text.');
|
|
316
|
+
}
|
|
317
|
+
form.append('file', new Blob([bytes], { type: args.mime_type || mimeFromFilename(fileName) }), fileName);
|
|
318
|
+
return this.request('POST', '/files/upload', form);
|
|
319
|
+
}
|
|
320
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webhound-mcp",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "MCP server that lets agents run Webhound reports and datasets, watch long sessions, steer them, diagnose failures, and read cited outputs.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"webhound-mcp": "bin/server.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"files": ["bin/", "core/", "README.md"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"self-test": "node bin/server.mjs --self-test",
|
|
13
|
+
"mcp:self-test": "node bin/server.mjs --self-test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
17
|
+
"zod": "^3.23.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["mcp", "model-context-protocol", "agent", "research", "deep-research", "webhound", "claude", "cursor", "codex"],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": { "type": "git", "url": "https://github.com/webhound/webhound" },
|
|
22
|
+
"homepage": "https://webhound.ai"
|
|
23
|
+
}
|