@meltingpixels/harvey-tools 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/ecosystem.config.cjs +17 -0
- package/package.json +37 -0
- package/server.json +30 -0
- package/src/config.ts +34 -0
- package/src/discovery.ts +277 -0
- package/src/index.ts +259 -0
- package/src/lib/browser.ts +75 -0
- package/src/lib/grok-client.ts +63 -0
- package/src/tools/code-review.ts +68 -0
- package/src/tools/content.ts +61 -0
- package/src/tools/scraping.ts +102 -0
- package/src/tools/sentiment.ts +53 -0
- package/tsconfig.json +19 -0
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
apps: [
|
|
3
|
+
{
|
|
4
|
+
name: "harvey-tools",
|
|
5
|
+
script: "dist/index.js",
|
|
6
|
+
cwd: "/home/deploy/projects/harvey-tools",
|
|
7
|
+
node_args: "--enable-source-maps",
|
|
8
|
+
env: {
|
|
9
|
+
NODE_ENV: "production",
|
|
10
|
+
PORT: 8403,
|
|
11
|
+
},
|
|
12
|
+
max_memory_restart: "512M",
|
|
13
|
+
autorestart: true,
|
|
14
|
+
watch: false,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meltingpixels/harvey-tools",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "x402-paid MCP server for web scraping, code review, content generation, and analysis. Pay-per-call USDC on Solana.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.meltingpixelsai/harvey-tools",
|
|
7
|
+
"keywords": ["mcp", "x402", "usdc", "ai-agents", "web-scraping", "code-review", "content-generation", "sentiment-analysis"],
|
|
8
|
+
"author": "MeltingPixels <jeremy@meltingpixels.com>",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/meltingpixelsai/harvey-tools"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://tools.rugslayer.com",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsx watch src/index.ts",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@hono/node-server": "^1.19.5",
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.18.2",
|
|
24
|
+
"dotenv": "^16.4.7",
|
|
25
|
+
"hono": "^4.9.6",
|
|
26
|
+
"mcp-handler": "^1.0.2",
|
|
27
|
+
"mcpay": "^0.1.17",
|
|
28
|
+
"playwright": "^1.50.0",
|
|
29
|
+
"x402": "^0.6.5",
|
|
30
|
+
"zod": "^3.25.76"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.15.3",
|
|
34
|
+
"tsx": "^4.19.4",
|
|
35
|
+
"typescript": "^5.8.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "io.github.meltingpixelsai/harvey-tools",
|
|
3
|
+
"description": "Web scraping, code review, content generation, and analysis tools for AI agents. Pay-per-call USDC.",
|
|
4
|
+
"packages": [
|
|
5
|
+
{
|
|
6
|
+
"registryType": "npm",
|
|
7
|
+
"identifier": "@meltingpixels/harvey-tools",
|
|
8
|
+
"transport": {
|
|
9
|
+
"type": "streamable-http",
|
|
10
|
+
"url": "https://tools.rugslayer.com/mcp"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"_meta": {
|
|
15
|
+
"x402": {
|
|
16
|
+
"network": "solana",
|
|
17
|
+
"currency": "USDC",
|
|
18
|
+
"facilitator": "https://facilitator.payai.network",
|
|
19
|
+
"tools": {
|
|
20
|
+
"scrape_url": "$0.005",
|
|
21
|
+
"screenshot_url": "$0.005",
|
|
22
|
+
"extract_structured_data": "$0.02",
|
|
23
|
+
"review_code": "$0.03",
|
|
24
|
+
"generate_content": "$0.05",
|
|
25
|
+
"analyze_sentiment": "$0.01"
|
|
26
|
+
},
|
|
27
|
+
"free_tools": ["list_tools", "health"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
port: parseInt(process.env.PORT || "8403", 10),
|
|
3
|
+
|
|
4
|
+
// Grok API (xAI)
|
|
5
|
+
grok: {
|
|
6
|
+
apiKey: process.env.XAI_API_KEY || "",
|
|
7
|
+
model: "grok-4-1-fast",
|
|
8
|
+
apiUrl: "https://api.x.ai/v1/chat/completions",
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
// x402 payment config
|
|
12
|
+
payment: {
|
|
13
|
+
wallet: process.env.PAYMENT_WALLET || "2MB8Gk4PebwhP6yaiiMjofHYoQvvQ8iWo3hdkUHQ1Wdq",
|
|
14
|
+
facilitator: process.env.X402_FACILITATOR || "https://facilitator.payai.network",
|
|
15
|
+
network: "solana" as const,
|
|
16
|
+
currency: "USDC",
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// Tool pricing (in USD)
|
|
20
|
+
pricing: {
|
|
21
|
+
scrape_url: 0.005,
|
|
22
|
+
screenshot_url: 0.005,
|
|
23
|
+
extract_structured_data: 0.02,
|
|
24
|
+
review_code: 0.03,
|
|
25
|
+
generate_content: 0.05,
|
|
26
|
+
analyze_sentiment: 0.01,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// Browser config
|
|
30
|
+
browser: {
|
|
31
|
+
timeout: 30_000,
|
|
32
|
+
maxContentLength: 50_000,
|
|
33
|
+
},
|
|
34
|
+
} as const;
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
/** Register all agent discovery routes on the Hono app */
|
|
4
|
+
export function registerDiscoveryRoutes(app: Hono): void {
|
|
5
|
+
app.get("/llms.txt", (c) => {
|
|
6
|
+
return c.text(LLMS_TXT, 200, {
|
|
7
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
8
|
+
"Cache-Control": "public, max-age=3600",
|
|
9
|
+
"Access-Control-Allow-Origin": "*",
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.get("/.well-known/agent-card.json", (c) => {
|
|
14
|
+
return c.json(AGENT_CARD, 200, {
|
|
15
|
+
"Cache-Control": "public, max-age=3600",
|
|
16
|
+
"Access-Control-Allow-Origin": "*",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.get("/.well-known/mcp.json", (c) => {
|
|
21
|
+
return c.json(MCP_CARD, 200, {
|
|
22
|
+
"Cache-Control": "public, max-age=3600",
|
|
23
|
+
"Access-Control-Allow-Origin": "*",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Static Content ────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const LLMS_TXT = `# Harvey Tools - General-Purpose Agent Tools MCP Server
|
|
31
|
+
|
|
32
|
+
> MCP server for AI agents. Web scraping, screenshots, structured data extraction, code review, content generation, sentiment analysis.
|
|
33
|
+
> Pay per call with USDC via x402 micropayments. No account needed.
|
|
34
|
+
> Built by MeltingPixels.
|
|
35
|
+
|
|
36
|
+
## Tools (8 total, 2 free + 6 paid)
|
|
37
|
+
- [list_tools](https://tools.rugslayer.com/mcp): List all tools with pricing (FREE)
|
|
38
|
+
- [health](https://tools.rugslayer.com/mcp): Server status and payment config (FREE)
|
|
39
|
+
- [scrape_url](https://tools.rugslayer.com/mcp): Scrape any URL, return cleaned text ($0.005)
|
|
40
|
+
- [screenshot_url](https://tools.rugslayer.com/mcp): Full-page screenshot as base64 PNG ($0.005)
|
|
41
|
+
- [extract_structured_data](https://tools.rugslayer.com/mcp): Scrape URL + LLM extract structured JSON ($0.02)
|
|
42
|
+
- [review_code](https://tools.rugslayer.com/mcp): Security + quality code review ($0.03)
|
|
43
|
+
- [generate_content](https://tools.rugslayer.com/mcp): Generate blog posts, docs, descriptions ($0.05)
|
|
44
|
+
- [analyze_sentiment](https://tools.rugslayer.com/mcp): Sentiment analysis + entity extraction ($0.01)
|
|
45
|
+
|
|
46
|
+
## Connection
|
|
47
|
+
- [MCP Endpoint](https://tools.rugslayer.com/mcp): Connect directly via MCP
|
|
48
|
+
- [npm](https://www.npmjs.com/package/@meltingpixels/harvey-tools): @meltingpixels/harvey-tools
|
|
49
|
+
- [Claude Code](https://tools.rugslayer.com/mcp): claude mcp add harvey-tools --transport http https://tools.rugslayer.com/mcp
|
|
50
|
+
|
|
51
|
+
## Authentication
|
|
52
|
+
- [x402 USDC](https://tools.rugslayer.com/mcp): Pay per call on Solana, no account needed
|
|
53
|
+
|
|
54
|
+
## Pricing
|
|
55
|
+
- scrape_url: $0.005 USDC per call
|
|
56
|
+
- screenshot_url: $0.005 USDC per call
|
|
57
|
+
- extract_structured_data: $0.02 USDC per call
|
|
58
|
+
- review_code: $0.03 USDC per call
|
|
59
|
+
- generate_content: $0.05 USDC per call
|
|
60
|
+
- analyze_sentiment: $0.01 USDC per call
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const AGENT_CARD = {
|
|
64
|
+
name: "Harvey Tools",
|
|
65
|
+
description:
|
|
66
|
+
"MCP server for AI agents providing web scraping, screenshots, structured data extraction, code review, content generation, and sentiment analysis. Pay per call with USDC via x402.",
|
|
67
|
+
url: "https://tools.rugslayer.com/mcp",
|
|
68
|
+
version: "1.0.0",
|
|
69
|
+
provider: {
|
|
70
|
+
organization: "MeltingPixels",
|
|
71
|
+
url: "https://rugslayer.com",
|
|
72
|
+
},
|
|
73
|
+
capabilities: {
|
|
74
|
+
streaming: false,
|
|
75
|
+
pushNotifications: false,
|
|
76
|
+
stateTransitionHistory: false,
|
|
77
|
+
},
|
|
78
|
+
authentication: {
|
|
79
|
+
schemes: ["x402"],
|
|
80
|
+
},
|
|
81
|
+
defaultInputModes: ["application/json"],
|
|
82
|
+
defaultOutputModes: ["application/json"],
|
|
83
|
+
skills: [
|
|
84
|
+
{
|
|
85
|
+
id: "web-scraping",
|
|
86
|
+
name: "Web Scraping",
|
|
87
|
+
description: "Scrape any URL and return cleaned text content. Powered by Playwright headless browser.",
|
|
88
|
+
tags: ["scraping", "web", "playwright", "text-extraction"],
|
|
89
|
+
examples: ["Scrape this webpage for me", "Get the text content from this URL"],
|
|
90
|
+
inputModes: ["application/json"],
|
|
91
|
+
outputModes: ["application/json"],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "screenshots",
|
|
95
|
+
name: "URL Screenshots",
|
|
96
|
+
description: "Capture full-page screenshots of any URL. Returns base64-encoded PNG.",
|
|
97
|
+
tags: ["screenshot", "web", "playwright", "image"],
|
|
98
|
+
examples: ["Take a screenshot of this website", "Capture this page"],
|
|
99
|
+
inputModes: ["application/json"],
|
|
100
|
+
outputModes: ["application/json"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "structured-data",
|
|
104
|
+
name: "Structured Data Extraction",
|
|
105
|
+
description: "Scrape a URL and extract structured JSON data matching a provided schema using AI.",
|
|
106
|
+
tags: ["scraping", "extraction", "ai", "json", "structured-data"],
|
|
107
|
+
examples: ["Extract product prices from this page", "Get all the contact info from this URL"],
|
|
108
|
+
inputModes: ["application/json"],
|
|
109
|
+
outputModes: ["application/json"],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "code-review",
|
|
113
|
+
name: "Code Review",
|
|
114
|
+
description: "AI-powered security and quality review of submitted code. Finds vulnerabilities, anti-patterns, and suggests improvements.",
|
|
115
|
+
tags: ["code-review", "security", "quality", "ai"],
|
|
116
|
+
examples: ["Review this code for security issues", "Check this function for bugs"],
|
|
117
|
+
inputModes: ["application/json"],
|
|
118
|
+
outputModes: ["application/json"],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "content-generation",
|
|
122
|
+
name: "Content Generation",
|
|
123
|
+
description: "Generate blog posts, product descriptions, documentation, social posts, and emails.",
|
|
124
|
+
tags: ["content", "generation", "ai", "writing"],
|
|
125
|
+
examples: ["Write a blog post about AI agents", "Generate a product description"],
|
|
126
|
+
inputModes: ["application/json"],
|
|
127
|
+
outputModes: ["application/json"],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "sentiment-analysis",
|
|
131
|
+
name: "Sentiment Analysis",
|
|
132
|
+
description: "Analyze sentiment of text with entity extraction, key phrases, and confidence scores.",
|
|
133
|
+
tags: ["sentiment", "analysis", "nlp", "ai"],
|
|
134
|
+
examples: ["What's the sentiment of this review?", "Analyze the tone of this text"],
|
|
135
|
+
inputModes: ["application/json"],
|
|
136
|
+
outputModes: ["application/json"],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const MCP_CARD = {
|
|
142
|
+
mcp_version: "2025-11-25",
|
|
143
|
+
name: "harvey-tools",
|
|
144
|
+
display_name: "Harvey Tools - General-Purpose Agent Tools",
|
|
145
|
+
description:
|
|
146
|
+
"MCP server for AI agents. Web scraping, screenshots, structured data extraction, code review, content generation, and sentiment analysis. Pay per call with USDC via x402.",
|
|
147
|
+
version: "1.0.0",
|
|
148
|
+
vendor: "MeltingPixels",
|
|
149
|
+
homepage: "https://tools.rugslayer.com",
|
|
150
|
+
endpoints: {
|
|
151
|
+
streamable_http: "https://tools.rugslayer.com/mcp",
|
|
152
|
+
},
|
|
153
|
+
pricing: {
|
|
154
|
+
model: "paid",
|
|
155
|
+
free_tools: ["list_tools", "health"],
|
|
156
|
+
paid_tools: {
|
|
157
|
+
scrape_url: "$0.005",
|
|
158
|
+
screenshot_url: "$0.005",
|
|
159
|
+
extract_structured_data: "$0.02",
|
|
160
|
+
review_code: "$0.03",
|
|
161
|
+
generate_content: "$0.05",
|
|
162
|
+
analyze_sentiment: "$0.01",
|
|
163
|
+
},
|
|
164
|
+
payment_methods: ["x402_usdc_solana"],
|
|
165
|
+
},
|
|
166
|
+
rate_limits: {
|
|
167
|
+
x402: "unlimited (pay per call)",
|
|
168
|
+
},
|
|
169
|
+
tools: [
|
|
170
|
+
{
|
|
171
|
+
name: "list_tools",
|
|
172
|
+
description: "List all available tools with pricing and input requirements.",
|
|
173
|
+
price: "FREE",
|
|
174
|
+
input_schema: { type: "object", properties: {} },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "health",
|
|
178
|
+
description: "Server status, uptime, and payment network configuration.",
|
|
179
|
+
price: "FREE",
|
|
180
|
+
input_schema: { type: "object", properties: {} },
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "scrape_url",
|
|
184
|
+
description: "Scrape any URL and return cleaned markdown text. Powered by Playwright.",
|
|
185
|
+
price: "$0.005 USDC",
|
|
186
|
+
input_schema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
required: ["url"],
|
|
189
|
+
properties: {
|
|
190
|
+
url: { type: "string", description: "URL to scrape" },
|
|
191
|
+
max_length: { type: "number", description: "Max content length in chars (default: 10000)" },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "screenshot_url",
|
|
197
|
+
description: "Full-page screenshot of any URL. Returns base64-encoded PNG.",
|
|
198
|
+
price: "$0.005 USDC",
|
|
199
|
+
input_schema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
required: ["url"],
|
|
202
|
+
properties: {
|
|
203
|
+
url: { type: "string", description: "URL to screenshot" },
|
|
204
|
+
full_page: { type: "boolean", description: "Capture full page (default: true)" },
|
|
205
|
+
width: { type: "number", description: "Viewport width (default: 1280)" },
|
|
206
|
+
height: { type: "number", description: "Viewport height (default: 720)" },
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "extract_structured_data",
|
|
212
|
+
description: "Scrape URL then extract structured JSON via AI per your schema description.",
|
|
213
|
+
price: "$0.02 USDC",
|
|
214
|
+
input_schema: {
|
|
215
|
+
type: "object",
|
|
216
|
+
required: ["url", "schema_description"],
|
|
217
|
+
properties: {
|
|
218
|
+
url: { type: "string", description: "URL to scrape" },
|
|
219
|
+
schema_description: { type: "string", description: "Description of data to extract and desired JSON structure" },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "review_code",
|
|
225
|
+
description: "AI-powered security and quality code review.",
|
|
226
|
+
price: "$0.03 USDC",
|
|
227
|
+
input_schema: {
|
|
228
|
+
type: "object",
|
|
229
|
+
required: ["code"],
|
|
230
|
+
properties: {
|
|
231
|
+
code: { type: "string", description: "Code to review" },
|
|
232
|
+
language: { type: "string", description: "Programming language (auto-detected if omitted)" },
|
|
233
|
+
focus: { type: "string", description: "Focus area: security, quality, performance, or all (default: all)" },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "generate_content",
|
|
239
|
+
description: "Generate blog posts, product descriptions, documentation, social posts, emails.",
|
|
240
|
+
price: "$0.05 USDC",
|
|
241
|
+
input_schema: {
|
|
242
|
+
type: "object",
|
|
243
|
+
required: ["type", "topic"],
|
|
244
|
+
properties: {
|
|
245
|
+
type: { type: "string", description: "Content type: blog_post, product_description, documentation, social_post, email" },
|
|
246
|
+
topic: { type: "string", description: "Topic or subject to write about" },
|
|
247
|
+
tone: { type: "string", description: "Tone: professional, casual, technical, friendly (default: professional)" },
|
|
248
|
+
length: { type: "string", description: "Length: short, medium, long (default: medium)" },
|
|
249
|
+
keywords: { type: "string", description: "Comma-separated keywords to include" },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "analyze_sentiment",
|
|
255
|
+
description: "Sentiment analysis with entity extraction and key phrases.",
|
|
256
|
+
price: "$0.01 USDC",
|
|
257
|
+
input_schema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
required: ["text"],
|
|
260
|
+
properties: {
|
|
261
|
+
text: { type: "string", description: "Text to analyze" },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
install: {
|
|
267
|
+
npm: "npx -y @meltingpixels/harvey-tools",
|
|
268
|
+
claude_code: "claude mcp add harvey-tools --transport http https://tools.rugslayer.com/mcp",
|
|
269
|
+
claude_desktop: {
|
|
270
|
+
command: "npx",
|
|
271
|
+
args: ["-y", "@meltingpixels/harvey-tools"],
|
|
272
|
+
env: {},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
categories: ["web-scraping", "code-review", "content-generation", "analysis"],
|
|
276
|
+
tags: ["scraping", "playwright", "ai", "code-review", "content", "sentiment", "x402", "usdc"],
|
|
277
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { createMcpPaidHandler } from "mcpay/handler";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { config } from "./config.js";
|
|
7
|
+
import { registerDiscoveryRoutes } from "./discovery.js";
|
|
8
|
+
import { scrapeUrl, screenshotUrl, extractStructuredData } from "./tools/scraping.js";
|
|
9
|
+
import { reviewCode } from "./tools/code-review.js";
|
|
10
|
+
import { generateContent } from "./tools/content.js";
|
|
11
|
+
import { analyzeSentiment } from "./tools/sentiment.js";
|
|
12
|
+
|
|
13
|
+
// ── Shared tool callback helpers ─────────────────────────────
|
|
14
|
+
|
|
15
|
+
function toolResult(data: unknown) {
|
|
16
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toolError(err: unknown) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
22
|
+
isError: true as const,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Free tool data ───────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function listTools() {
|
|
29
|
+
return {
|
|
30
|
+
server: "harvey-tools",
|
|
31
|
+
version: "1.0.0",
|
|
32
|
+
payment: { network: config.payment.network, currency: config.payment.currency, method: "x402" },
|
|
33
|
+
tools: [
|
|
34
|
+
{ name: "list_tools", description: "List all tools with pricing", price: "FREE" },
|
|
35
|
+
{ name: "health", description: "Server status and payment config", price: "FREE" },
|
|
36
|
+
{ name: "scrape_url", description: "Scrape any URL, return cleaned text", price: "$0.005" },
|
|
37
|
+
{ name: "screenshot_url", description: "Full-page screenshot as base64 PNG", price: "$0.005" },
|
|
38
|
+
{ name: "extract_structured_data", description: "Scrape URL + AI extract structured JSON", price: "$0.02" },
|
|
39
|
+
{ name: "review_code", description: "Security + quality code review", price: "$0.03" },
|
|
40
|
+
{ name: "generate_content", description: "Generate blog posts, docs, descriptions", price: "$0.05" },
|
|
41
|
+
{ name: "analyze_sentiment", description: "Sentiment analysis + entity extraction", price: "$0.01" },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function health() {
|
|
47
|
+
return {
|
|
48
|
+
status: "ok",
|
|
49
|
+
server: "harvey-tools",
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
uptime: Math.floor(process.uptime()),
|
|
52
|
+
payment: {
|
|
53
|
+
network: config.payment.network,
|
|
54
|
+
currency: config.payment.currency,
|
|
55
|
+
wallet: config.payment.wallet,
|
|
56
|
+
facilitator: config.payment.facilitator,
|
|
57
|
+
method: "x402",
|
|
58
|
+
},
|
|
59
|
+
capabilities: ["web-scraping", "screenshots", "structured-extraction", "code-review", "content-generation", "sentiment-analysis"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Tool registration ────────────────────────────────────────
|
|
64
|
+
// Server typed as `any` because mcpay bundles its own @modelcontextprotocol/sdk
|
|
65
|
+
// version, making its McpServer type incompatible at compile time.
|
|
66
|
+
|
|
67
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
68
|
+
|
|
69
|
+
function registerFreeTools(server: any): void {
|
|
70
|
+
server.tool(
|
|
71
|
+
"list_tools",
|
|
72
|
+
"List all available Harvey Tools with pricing and input requirements. Use this for discovery.",
|
|
73
|
+
{},
|
|
74
|
+
async () => toolResult(listTools())
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.tool(
|
|
78
|
+
"health",
|
|
79
|
+
"Check Harvey Tools server status, uptime, and payment network configuration.",
|
|
80
|
+
{},
|
|
81
|
+
async () => toolResult(health())
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── x402 Paid Handler ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const paidHandler = createMcpPaidHandler(
|
|
88
|
+
(server) => {
|
|
89
|
+
registerFreeTools(server);
|
|
90
|
+
|
|
91
|
+
// ── Scraping tools ──
|
|
92
|
+
|
|
93
|
+
server.paidTool(
|
|
94
|
+
"scrape_url",
|
|
95
|
+
"Scrape any URL and return cleaned text content. Powered by Playwright headless browser. Returns title, content, word count.",
|
|
96
|
+
"$0.005",
|
|
97
|
+
{
|
|
98
|
+
url: z.string().url().describe("URL to scrape"),
|
|
99
|
+
max_length: z.number().min(100).max(50000).optional().describe("Max content length in chars (default: 10000)"),
|
|
100
|
+
},
|
|
101
|
+
{},
|
|
102
|
+
async ({ url, max_length }: { url: string; max_length?: number }) => {
|
|
103
|
+
try {
|
|
104
|
+
return toolResult(await scrapeUrl(url, max_length ?? 10_000));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return toolError(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
server.paidTool(
|
|
112
|
+
"screenshot_url",
|
|
113
|
+
"Take a full-page screenshot of any URL. Returns base64-encoded PNG image.",
|
|
114
|
+
"$0.005",
|
|
115
|
+
{
|
|
116
|
+
url: z.string().url().describe("URL to screenshot"),
|
|
117
|
+
full_page: z.boolean().optional().describe("Capture full page scroll height (default: true)"),
|
|
118
|
+
width: z.number().min(320).max(3840).optional().describe("Viewport width in pixels (default: 1280)"),
|
|
119
|
+
height: z.number().min(240).max(2160).optional().describe("Viewport height in pixels (default: 720)"),
|
|
120
|
+
},
|
|
121
|
+
{},
|
|
122
|
+
async ({ url, full_page, width, height }: { url: string; full_page?: boolean; width?: number; height?: number }) => {
|
|
123
|
+
try {
|
|
124
|
+
return toolResult(await screenshotUrl(url, full_page ?? true, width ?? 1280, height ?? 720));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return toolError(err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
server.paidTool(
|
|
132
|
+
"extract_structured_data",
|
|
133
|
+
"Scrape a URL then use AI to extract structured JSON data matching your schema description. Combines Playwright scraping with Grok LLM extraction.",
|
|
134
|
+
"$0.02",
|
|
135
|
+
{
|
|
136
|
+
url: z.string().url().describe("URL to scrape"),
|
|
137
|
+
schema_description: z.string().describe("Description of the data to extract and desired JSON structure. Example: 'Extract all product names and prices as {products: [{name, price}]}'"),
|
|
138
|
+
},
|
|
139
|
+
{},
|
|
140
|
+
async ({ url, schema_description }: { url: string; schema_description: string }) => {
|
|
141
|
+
try {
|
|
142
|
+
return toolResult(await extractStructuredData(url, schema_description));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return toolError(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// ── Code review ──
|
|
150
|
+
|
|
151
|
+
server.paidTool(
|
|
152
|
+
"review_code",
|
|
153
|
+
"AI-powered security and quality code review. Analyzes for vulnerabilities, anti-patterns, performance issues, and best practices. Returns issues with severity, suggestions, and an overall score.",
|
|
154
|
+
"$0.03",
|
|
155
|
+
{
|
|
156
|
+
code: z.string().describe("Source code to review"),
|
|
157
|
+
language: z.string().optional().describe("Programming language (auto-detected if omitted)"),
|
|
158
|
+
focus: z.string().optional().describe("Focus area: security, quality, performance, or all (default: all)"),
|
|
159
|
+
},
|
|
160
|
+
{},
|
|
161
|
+
async ({ code, language, focus }: { code: string; language?: string; focus?: string }) => {
|
|
162
|
+
try {
|
|
163
|
+
return toolResult(await reviewCode(code, language, focus));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return toolError(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// ── Content generation ──
|
|
171
|
+
|
|
172
|
+
server.paidTool(
|
|
173
|
+
"generate_content",
|
|
174
|
+
"Generate high-quality written content. Supports blog posts, product descriptions, documentation, social posts, and emails. Customizable tone, length, and keywords.",
|
|
175
|
+
"$0.05",
|
|
176
|
+
{
|
|
177
|
+
type: z.enum(["blog_post", "product_description", "documentation", "social_post", "email"]).describe("Content type"),
|
|
178
|
+
topic: z.string().describe("Topic or subject to write about"),
|
|
179
|
+
tone: z.enum(["professional", "casual", "technical", "friendly"]).optional().describe("Writing tone (default: professional)"),
|
|
180
|
+
length: z.enum(["short", "medium", "long"]).optional().describe("Target length (default: medium)"),
|
|
181
|
+
keywords: z.string().optional().describe("Comma-separated keywords to include"),
|
|
182
|
+
},
|
|
183
|
+
{},
|
|
184
|
+
async ({ type, topic, tone, length, keywords }: { type: string; topic: string; tone?: string; length?: string; keywords?: string }) => {
|
|
185
|
+
try {
|
|
186
|
+
return toolResult(await generateContent(type, topic, tone, length, keywords));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return toolError(err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// ── Sentiment analysis ──
|
|
194
|
+
|
|
195
|
+
server.paidTool(
|
|
196
|
+
"analyze_sentiment",
|
|
197
|
+
"Analyze sentiment of text with entity extraction, confidence scores, and key phrase identification. Returns positive/negative/neutral/mixed with detailed breakdown.",
|
|
198
|
+
"$0.01",
|
|
199
|
+
{
|
|
200
|
+
text: z.string().describe("Text to analyze for sentiment"),
|
|
201
|
+
},
|
|
202
|
+
{},
|
|
203
|
+
async ({ text }: { text: string }) => {
|
|
204
|
+
try {
|
|
205
|
+
return toolResult(await analyzeSentiment(text));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return toolError(err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
facilitator: {
|
|
214
|
+
url: config.payment.facilitator as `${string}://${string}`,
|
|
215
|
+
},
|
|
216
|
+
recipient: {
|
|
217
|
+
svm: {
|
|
218
|
+
address: config.payment.wallet,
|
|
219
|
+
isTestnet: false,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
serverInfo: { name: "harvey-tools", version: "1.0.0" },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
maxDuration: 300,
|
|
228
|
+
verboseLogs: process.env.NODE_ENV !== "production",
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// ── Hono HTTP Server ─────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
const app = new Hono();
|
|
235
|
+
|
|
236
|
+
// Health + pricing endpoints (outside MCP, for monitoring/discovery)
|
|
237
|
+
app.get("/health", (c) => c.json(health()));
|
|
238
|
+
app.get("/pricing", (c) => c.json(listTools()));
|
|
239
|
+
|
|
240
|
+
// Agent discovery routes
|
|
241
|
+
registerDiscoveryRoutes(app);
|
|
242
|
+
|
|
243
|
+
// MCP handler — x402 only (no API key auth needed)
|
|
244
|
+
app.all("*", async (c) => {
|
|
245
|
+
return paidHandler(c.req.raw);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── Start ────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
serve({ fetch: app.fetch, port: config.port }, () => {
|
|
251
|
+
console.log(`Harvey Tools MCP server running on port ${config.port}`);
|
|
252
|
+
console.log(` MCP endpoint: http://localhost:${config.port}/`);
|
|
253
|
+
console.log(` Health: http://localhost:${config.port}/health`);
|
|
254
|
+
console.log(` Pricing: http://localhost:${config.port}/pricing`);
|
|
255
|
+
console.log(` Auth: x402 USDC only`);
|
|
256
|
+
console.log(` Payment wallet: ${config.payment.wallet}`);
|
|
257
|
+
console.log(` Facilitator: ${config.payment.facilitator}`);
|
|
258
|
+
console.log(` Network: ${config.payment.network} (${config.payment.currency})`);
|
|
259
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chromium, type Browser, type Page } from "playwright";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
|
|
4
|
+
let browser: Browser | null = null;
|
|
5
|
+
|
|
6
|
+
/** Get or create the shared Playwright browser instance */
|
|
7
|
+
async function getBrowser(): Promise<Browser> {
|
|
8
|
+
if (!browser || !browser.isConnected()) {
|
|
9
|
+
browser = await chromium.launch({
|
|
10
|
+
headless: true,
|
|
11
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return browser;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Create a new page with standard config. Caller MUST close the page when done. */
|
|
18
|
+
export async function createPage(options?: {
|
|
19
|
+
blockMedia?: boolean;
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
}): Promise<Page> {
|
|
23
|
+
const b = await getBrowser();
|
|
24
|
+
const page = await b.newPage({
|
|
25
|
+
viewport: {
|
|
26
|
+
width: options?.width ?? 1280,
|
|
27
|
+
height: options?.height ?? 720,
|
|
28
|
+
},
|
|
29
|
+
userAgent:
|
|
30
|
+
"Mozilla/5.0 (compatible; HarveyTools/1.0; +https://tools.rugslayer.com) AppleWebKit/537.36 Chrome/120.0.0.0",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Block heavy resources for scraping (faster + cheaper)
|
|
34
|
+
if (options?.blockMedia !== false) {
|
|
35
|
+
await page.route("**/*", (route) => {
|
|
36
|
+
const type = route.request().resourceType();
|
|
37
|
+
if (["image", "font", "media", "stylesheet"].includes(type)) {
|
|
38
|
+
return route.abort();
|
|
39
|
+
}
|
|
40
|
+
return route.continue();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
page.setDefaultTimeout(config.browser.timeout);
|
|
45
|
+
return page;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Navigate to URL and wait for content to settle */
|
|
49
|
+
export async function navigateTo(page: Page, url: string): Promise<void> {
|
|
50
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: config.browser.timeout });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract text content from the current page */
|
|
54
|
+
export async function extractText(page: Page, maxLength: number): Promise<{ title: string; content: string }> {
|
|
55
|
+
const title = await page.title();
|
|
56
|
+
const content = await page.evaluate(() => {
|
|
57
|
+
// Remove script/style/nav/footer noise
|
|
58
|
+
const remove = document.querySelectorAll("script, style, nav, footer, header, aside, [role=banner], [role=navigation]");
|
|
59
|
+
remove.forEach((el) => el.remove());
|
|
60
|
+
return document.body?.innerText?.trim() || "";
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
title,
|
|
65
|
+
content: content.slice(0, maxLength),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Gracefully close the browser (for cleanup) */
|
|
70
|
+
export async function closeBrowser(): Promise<void> {
|
|
71
|
+
if (browser) {
|
|
72
|
+
await browser.close();
|
|
73
|
+
browser = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
|
|
3
|
+
interface GrokMessage {
|
|
4
|
+
role: "system" | "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface GrokOptions {
|
|
9
|
+
temperature?: number;
|
|
10
|
+
maxTokens?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface GrokResponse {
|
|
14
|
+
content: string;
|
|
15
|
+
model: string;
|
|
16
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Call Grok API and return the assistant's response text */
|
|
20
|
+
export async function callGrok(
|
|
21
|
+
messages: GrokMessage[],
|
|
22
|
+
options?: GrokOptions
|
|
23
|
+
): Promise<GrokResponse> {
|
|
24
|
+
if (!config.grok.apiKey) {
|
|
25
|
+
throw new Error("XAI_API_KEY not configured");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const res = await fetch(config.grok.apiUrl, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
Authorization: `Bearer ${config.grok.apiKey}`,
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
model: config.grok.model,
|
|
36
|
+
messages,
|
|
37
|
+
temperature: options?.temperature ?? 0.3,
|
|
38
|
+
max_tokens: options?.maxTokens ?? 4096,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const body = await res.text();
|
|
44
|
+
throw new Error(`Grok API error ${res.status}: ${body}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = (await res.json()) as {
|
|
48
|
+
choices: Array<{ message: { content: string } }>;
|
|
49
|
+
model: string;
|
|
50
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const content = data.choices?.[0]?.message?.content;
|
|
54
|
+
if (!content) {
|
|
55
|
+
throw new Error("Grok API returned empty response");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
content,
|
|
60
|
+
model: data.model,
|
|
61
|
+
usage: data.usage,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { callGrok } from "../lib/grok-client.js";
|
|
2
|
+
|
|
3
|
+
interface CodeIssue {
|
|
4
|
+
severity: "critical" | "high" | "medium" | "low" | "info";
|
|
5
|
+
line?: number;
|
|
6
|
+
message: string;
|
|
7
|
+
suggestion: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Review code for security, quality, and performance issues */
|
|
11
|
+
export async function reviewCode(
|
|
12
|
+
code: string,
|
|
13
|
+
language?: string,
|
|
14
|
+
focus?: string
|
|
15
|
+
): Promise<{
|
|
16
|
+
language: string;
|
|
17
|
+
issues: CodeIssue[];
|
|
18
|
+
summary: string;
|
|
19
|
+
score: number;
|
|
20
|
+
}> {
|
|
21
|
+
const focusArea = focus || "all";
|
|
22
|
+
const focusInstruction =
|
|
23
|
+
focusArea === "all"
|
|
24
|
+
? "Review for security vulnerabilities, code quality, performance, and best practices."
|
|
25
|
+
: `Focus specifically on: ${focusArea}.`;
|
|
26
|
+
|
|
27
|
+
const response = await callGrok(
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
role: "system",
|
|
31
|
+
content: `You are an expert code reviewer. ${focusInstruction}
|
|
32
|
+
|
|
33
|
+
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
|
|
34
|
+
{
|
|
35
|
+
"language": "detected language",
|
|
36
|
+
"issues": [
|
|
37
|
+
{
|
|
38
|
+
"severity": "critical|high|medium|low|info",
|
|
39
|
+
"line": null,
|
|
40
|
+
"message": "what's wrong",
|
|
41
|
+
"suggestion": "how to fix it"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"summary": "brief overall assessment",
|
|
45
|
+
"score": 85
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Score from 0-100 where 100 is perfect. Be thorough but fair. If the code is good, say so.`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
role: "user",
|
|
52
|
+
content: `Review this ${language || ""}code:\n\n${code}`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
{ temperature: 0.2, maxTokens: 4096 }
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(response.content);
|
|
60
|
+
} catch {
|
|
61
|
+
return {
|
|
62
|
+
language: language || "unknown",
|
|
63
|
+
issues: [],
|
|
64
|
+
summary: response.content,
|
|
65
|
+
score: 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { callGrok } from "../lib/grok-client.js";
|
|
2
|
+
|
|
3
|
+
const SYSTEM_PROMPTS: Record<string, string> = {
|
|
4
|
+
blog_post:
|
|
5
|
+
"You are a skilled blog writer. Write engaging, well-structured blog posts with clear headings, intro, body, and conclusion. Use markdown formatting.",
|
|
6
|
+
product_description:
|
|
7
|
+
"You are a conversion-focused copywriter. Write compelling product descriptions that highlight benefits, features, and include a call to action.",
|
|
8
|
+
documentation:
|
|
9
|
+
"You are a technical writer. Write clear, precise documentation with examples. Use markdown with code blocks where appropriate.",
|
|
10
|
+
social_post:
|
|
11
|
+
"You are a social media expert. Write concise, engaging posts optimized for engagement. Include a hook, value, and CTA. No hashtag spam.",
|
|
12
|
+
email:
|
|
13
|
+
"You are an email copywriter. Write professional emails with clear subject line suggestions, compelling body, and strong CTA.",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const LENGTH_TOKENS: Record<string, number> = {
|
|
17
|
+
short: 1024,
|
|
18
|
+
medium: 2048,
|
|
19
|
+
long: 4096,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Generate content based on type, topic, and parameters */
|
|
23
|
+
export async function generateContent(
|
|
24
|
+
type: string,
|
|
25
|
+
topic: string,
|
|
26
|
+
tone?: string,
|
|
27
|
+
length?: string,
|
|
28
|
+
keywords?: string
|
|
29
|
+
): Promise<{
|
|
30
|
+
type: string;
|
|
31
|
+
content: string;
|
|
32
|
+
word_count: number;
|
|
33
|
+
model: string;
|
|
34
|
+
}> {
|
|
35
|
+
const systemPrompt = SYSTEM_PROMPTS[type] || SYSTEM_PROMPTS.blog_post;
|
|
36
|
+
const toneStr = tone || "professional";
|
|
37
|
+
const maxTokens = LENGTH_TOKENS[length || "medium"] ?? 2048;
|
|
38
|
+
|
|
39
|
+
let userPrompt = `Write a ${type.replace(/_/g, " ")} about: ${topic}\n\nTone: ${toneStr}`;
|
|
40
|
+
if (keywords) {
|
|
41
|
+
userPrompt += `\nKeywords to include: ${keywords}`;
|
|
42
|
+
}
|
|
43
|
+
if (length) {
|
|
44
|
+
userPrompt += `\nTarget length: ${length}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await callGrok(
|
|
48
|
+
[
|
|
49
|
+
{ role: "system", content: systemPrompt },
|
|
50
|
+
{ role: "user", content: userPrompt },
|
|
51
|
+
],
|
|
52
|
+
{ temperature: 0.7, maxTokens }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
type,
|
|
57
|
+
content: response.content,
|
|
58
|
+
word_count: response.content.split(/\s+/).filter(Boolean).length,
|
|
59
|
+
model: response.model,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createPage, navigateTo, extractText } from "../lib/browser.js";
|
|
2
|
+
import { callGrok } from "../lib/grok-client.js";
|
|
3
|
+
|
|
4
|
+
/** Scrape a URL and return cleaned text content */
|
|
5
|
+
export async function scrapeUrl(
|
|
6
|
+
url: string,
|
|
7
|
+
maxLength: number = 10_000
|
|
8
|
+
): Promise<{
|
|
9
|
+
url: string;
|
|
10
|
+
title: string;
|
|
11
|
+
content: string;
|
|
12
|
+
word_count: number;
|
|
13
|
+
scraped_at: string;
|
|
14
|
+
}> {
|
|
15
|
+
const page = await createPage({ blockMedia: true });
|
|
16
|
+
try {
|
|
17
|
+
await navigateTo(page, url);
|
|
18
|
+
const { title, content } = await extractText(page, maxLength);
|
|
19
|
+
return {
|
|
20
|
+
url,
|
|
21
|
+
title,
|
|
22
|
+
content,
|
|
23
|
+
word_count: content.split(/\s+/).filter(Boolean).length,
|
|
24
|
+
scraped_at: new Date().toISOString(),
|
|
25
|
+
};
|
|
26
|
+
} finally {
|
|
27
|
+
await page.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Take a screenshot of a URL and return base64 PNG */
|
|
32
|
+
export async function screenshotUrl(
|
|
33
|
+
url: string,
|
|
34
|
+
fullPage: boolean = true,
|
|
35
|
+
width: number = 1280,
|
|
36
|
+
height: number = 720
|
|
37
|
+
): Promise<{
|
|
38
|
+
url: string;
|
|
39
|
+
image_base64: string;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
format: string;
|
|
43
|
+
}> {
|
|
44
|
+
// Don't block images for screenshots
|
|
45
|
+
const page = await createPage({ blockMedia: false, width, height });
|
|
46
|
+
try {
|
|
47
|
+
await navigateTo(page, url);
|
|
48
|
+
const buffer = await page.screenshot({ fullPage, type: "png" });
|
|
49
|
+
return {
|
|
50
|
+
url,
|
|
51
|
+
image_base64: buffer.toString("base64"),
|
|
52
|
+
width,
|
|
53
|
+
height,
|
|
54
|
+
format: "png",
|
|
55
|
+
};
|
|
56
|
+
} finally {
|
|
57
|
+
await page.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Scrape URL then extract structured data via Grok */
|
|
62
|
+
export async function extractStructuredData(
|
|
63
|
+
url: string,
|
|
64
|
+
schemaDescription: string
|
|
65
|
+
): Promise<{
|
|
66
|
+
url: string;
|
|
67
|
+
data: unknown;
|
|
68
|
+
model: string;
|
|
69
|
+
}> {
|
|
70
|
+
// Scrape the page first
|
|
71
|
+
const scraped = await scrapeUrl(url, 30_000);
|
|
72
|
+
|
|
73
|
+
// Send to Grok for structured extraction
|
|
74
|
+
const response = await callGrok(
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
role: "system",
|
|
78
|
+
content:
|
|
79
|
+
"You are a data extraction assistant. Extract structured data from the provided webpage content. Return ONLY valid JSON - no markdown, no explanation, no code fences. If you cannot extract the requested data, return an empty object {}.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
role: "user",
|
|
83
|
+
content: `Extract structured data from this webpage content matching this schema:\n\n${schemaDescription}\n\nWebpage title: ${scraped.title}\nWebpage content:\n${scraped.content}`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
{ temperature: 0.1, maxTokens: 4096 }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
let data: unknown;
|
|
90
|
+
try {
|
|
91
|
+
data = JSON.parse(response.content);
|
|
92
|
+
} catch {
|
|
93
|
+
// If Grok didn't return valid JSON, wrap the raw text
|
|
94
|
+
data = { raw_response: response.content };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
url,
|
|
99
|
+
data,
|
|
100
|
+
model: response.model,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { callGrok } from "../lib/grok-client.js";
|
|
2
|
+
|
|
3
|
+
interface Entity {
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
sentiment: "positive" | "negative" | "neutral";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Analyze sentiment of text with entity extraction */
|
|
10
|
+
export async function analyzeSentiment(text: string): Promise<{
|
|
11
|
+
sentiment: "positive" | "negative" | "neutral" | "mixed";
|
|
12
|
+
confidence: number;
|
|
13
|
+
entities: Entity[];
|
|
14
|
+
summary: string;
|
|
15
|
+
key_phrases: string[];
|
|
16
|
+
}> {
|
|
17
|
+
const response = await callGrok(
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
role: "system",
|
|
21
|
+
content: `You are a sentiment analysis expert. Analyze the provided text and return ONLY valid JSON with this exact structure (no markdown, no code fences):
|
|
22
|
+
{
|
|
23
|
+
"sentiment": "positive|negative|neutral|mixed",
|
|
24
|
+
"confidence": 0.95,
|
|
25
|
+
"entities": [
|
|
26
|
+
{ "name": "entity name", "type": "person|organization|product|location|other", "sentiment": "positive|negative|neutral" }
|
|
27
|
+
],
|
|
28
|
+
"summary": "brief sentiment summary in one sentence",
|
|
29
|
+
"key_phrases": ["phrase1", "phrase2"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Confidence is 0.0 to 1.0. Extract all named entities. Identify 3-5 key phrases.`,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
role: "user",
|
|
36
|
+
content: `Analyze the sentiment of this text:\n\n${text}`,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
{ temperature: 0.1, maxTokens: 2048 }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(response.content);
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
sentiment: "neutral",
|
|
47
|
+
confidence: 0,
|
|
48
|
+
entities: [],
|
|
49
|
+
summary: response.content,
|
|
50
|
+
key_phrases: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|