@site-spy/mcp-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/dist/index.mjs +200 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @site-spy/mcp-server
|
|
2
|
+
|
|
3
|
+
MCP server that lets AI agents monitor websites for changes via [Site Spy](https://sitespy.app).
|
|
4
|
+
|
|
5
|
+
Track pages, get notified when content changes, view diffs and snapshots — all from your AI assistant.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
Add to your AI client config:
|
|
10
|
+
|
|
11
|
+
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"site-spy": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@site-spy/mcp-server"],
|
|
19
|
+
"env": {
|
|
20
|
+
"SITE_SPY_API_KEY": "your-api-key"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Cursor** (`.cursor/mcp.json`):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"site-spy": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "@site-spy/mcp-server"],
|
|
35
|
+
"env": {
|
|
36
|
+
"SITE_SPY_API_KEY": "your-api-key"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Get your API key from [Site Spy Dashboard](https://app.sitespy.app/dashboard) → Settings → API.
|
|
44
|
+
|
|
45
|
+
If you don't set `SITE_SPY_API_KEY`, the agent will ask you to authenticate interactively.
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
| Tool | Description |
|
|
50
|
+
| -------------------- | ------------------------------------------------------ |
|
|
51
|
+
| `authenticate` | Connect with an API key (if not set via env var) |
|
|
52
|
+
| `auth_status` | Check authentication status |
|
|
53
|
+
| `list_watches` | List all monitored websites (optionally filter by tag) |
|
|
54
|
+
| `create_watch` | Start monitoring a URL for changes |
|
|
55
|
+
| `get_watch` | Get full details of a specific watch |
|
|
56
|
+
| `update_watch` | Update config — pause/resume, change interval, rename |
|
|
57
|
+
| `delete_watch` | Stop monitoring and delete a watch |
|
|
58
|
+
| `get_change_history` | Get timestamps of detected changes |
|
|
59
|
+
| `get_snapshot` | Get page content at a specific timestamp |
|
|
60
|
+
| `get_diff` | Compare content between two timestamps |
|
|
61
|
+
| `search_watches` | Search watches by URL or title |
|
|
62
|
+
| `list_tags` | List all tags for organizing watches |
|
|
63
|
+
| `trigger_recheck` | Force an immediate recheck of all watches |
|
|
64
|
+
|
|
65
|
+
## Environment Variables
|
|
66
|
+
|
|
67
|
+
| Variable | Description | Default |
|
|
68
|
+
| ------------------- | --------------------------------------- | ------------------------------------------- |
|
|
69
|
+
| `SITE_SPY_API_KEY` | API key for authentication | — (interactive auth) |
|
|
70
|
+
| `SITE_SPY_API_URL` | Backend API URL | `https://detect.coolify.vkuprin.com/api/v1` |
|
|
71
|
+
| `SITE_SPY_AUTH_URL` | URL shown to users for getting API keys | `https://app.sitespy.app/dashboard` |
|
|
72
|
+
|
|
73
|
+
## Example Prompts
|
|
74
|
+
|
|
75
|
+
Once configured, try asking your AI assistant:
|
|
76
|
+
|
|
77
|
+
- "Monitor https://example.com for changes every 30 minutes"
|
|
78
|
+
- "What websites am I monitoring?"
|
|
79
|
+
- "Show me what changed on my watched pages"
|
|
80
|
+
- "Pause all monitors tagged 'staging'"
|
|
81
|
+
- "Check all my sites right now"
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
ISC
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
const API_URL = process.env.SITE_SPY_API_URL ?? "https://detect.coolify.vkuprin.com/api/v1";
|
|
8
|
+
const AUTH_URL = process.env.SITE_SPY_AUTH_URL ?? "https://app.sitespy.app/dashboard";
|
|
9
|
+
let apiKey = process.env.SITE_SPY_API_KEY ?? "";
|
|
10
|
+
function requireAuth() {
|
|
11
|
+
if (!apiKey) throw new Error(`Not authenticated. Ask the user to open ${AUTH_URL} to log in and copy their API key, then call the 'authenticate' tool with it.`);
|
|
12
|
+
}
|
|
13
|
+
async function api(path, opts = {}) {
|
|
14
|
+
requireAuth();
|
|
15
|
+
const url = new URL(`${API_URL}${path}`);
|
|
16
|
+
if (opts.query) {
|
|
17
|
+
for (const [k, v] of Object.entries(opts.query)) if (v) url.searchParams.set(k, v);
|
|
18
|
+
}
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method: opts.method ?? "GET",
|
|
21
|
+
headers: {
|
|
22
|
+
"x-api-key": apiKey,
|
|
23
|
+
...opts.body ? { "Content-Type": "application/json" } : {}
|
|
24
|
+
},
|
|
25
|
+
body: opts.body ? JSON.stringify(opts.body) : void 0
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
if (!res.ok) throw new Error(`API ${res.status}: ${text}`);
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function jsonContent(data) {
|
|
36
|
+
return { content: [{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: JSON.stringify(data, null, 2)
|
|
39
|
+
}] };
|
|
40
|
+
}
|
|
41
|
+
const server = new McpServer({
|
|
42
|
+
name: "site-spy",
|
|
43
|
+
version: "1.0.0"
|
|
44
|
+
});
|
|
45
|
+
server.registerTool("authenticate", {
|
|
46
|
+
description: `Connect to Site Spy with an API key. If the user doesn't have one, ask them to open ${AUTH_URL} to log in and copy their key from the Settings > API tab.`,
|
|
47
|
+
inputSchema: z.object({ api_key: z.string().describe("Site Spy API key") })
|
|
48
|
+
}, async ({ api_key }) => {
|
|
49
|
+
const res = await fetch(`${API_URL}/watch`, { headers: { "x-api-key": api_key } });
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
throw new Error(`Invalid API key. Please check the key and try again. (${res.status}: ${text})`);
|
|
53
|
+
}
|
|
54
|
+
await res.text();
|
|
55
|
+
apiKey = api_key;
|
|
56
|
+
return jsonContent({
|
|
57
|
+
authenticated: true,
|
|
58
|
+
message: "Connected to Site Spy successfully."
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
server.registerTool("auth_status", {
|
|
62
|
+
description: "Check whether the MCP server is currently authenticated with Site Spy.",
|
|
63
|
+
inputSchema: z.object({})
|
|
64
|
+
}, async () => {
|
|
65
|
+
if (!apiKey) return jsonContent({
|
|
66
|
+
authenticated: false,
|
|
67
|
+
auth_url: AUTH_URL,
|
|
68
|
+
message: `Not connected. Ask the user to open ${AUTH_URL} to get their API key, then use the 'authenticate' tool.`
|
|
69
|
+
});
|
|
70
|
+
return jsonContent({ authenticated: true });
|
|
71
|
+
});
|
|
72
|
+
server.registerTool("list_watches", {
|
|
73
|
+
description: "List all monitored websites. Returns each watch with its UUID, URL, title, status, last checked/changed timestamps, and tags.",
|
|
74
|
+
inputSchema: z.object({ tag: z.string().optional().describe("Filter by tag UUID") })
|
|
75
|
+
}, async ({ tag }) => {
|
|
76
|
+
const query = {};
|
|
77
|
+
if (tag) query.tag = tag;
|
|
78
|
+
return jsonContent(await api("/watch", { query }));
|
|
79
|
+
});
|
|
80
|
+
server.registerTool("create_watch", {
|
|
81
|
+
description: "Start monitoring a URL for changes. Returns the UUID of the new watch.",
|
|
82
|
+
inputSchema: z.object({
|
|
83
|
+
url: z.string().describe("URL to monitor for changes"),
|
|
84
|
+
title: z.string().optional().describe("Human-readable name for this monitor"),
|
|
85
|
+
tag: z.string().optional().describe("Tag UUID to categorize this watch"),
|
|
86
|
+
check_hours: z.number().optional().describe("Hours between checks (default: uses account setting)"),
|
|
87
|
+
check_minutes: z.number().optional().describe("Minutes between checks"),
|
|
88
|
+
fetch_backend: z.enum(["html_requests", "html_webdriver"]).optional().describe("How to fetch the page: html_requests (fast, no JS) or html_webdriver (browser with JS)")
|
|
89
|
+
})
|
|
90
|
+
}, async ({ url, title, tag, check_hours, check_minutes, fetch_backend }) => {
|
|
91
|
+
const body = { url };
|
|
92
|
+
if (title) body.title = title;
|
|
93
|
+
if (tag) body.tag = tag;
|
|
94
|
+
if (fetch_backend) body.fetch_backend = fetch_backend;
|
|
95
|
+
if (check_hours !== void 0 || check_minutes !== void 0) {
|
|
96
|
+
body.time_between_check = {
|
|
97
|
+
...check_hours !== void 0 ? { hours: check_hours } : {},
|
|
98
|
+
...check_minutes !== void 0 ? { minutes: check_minutes } : {}
|
|
99
|
+
};
|
|
100
|
+
body.time_between_check_use_default = false;
|
|
101
|
+
}
|
|
102
|
+
return jsonContent(await api("/watch", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
body
|
|
105
|
+
}));
|
|
106
|
+
});
|
|
107
|
+
server.registerTool("get_watch", {
|
|
108
|
+
description: "Get full details of a specific watch including its configuration, last check status, and change history summary.",
|
|
109
|
+
inputSchema: z.object({ uuid: z.string().describe("Watch UUID") })
|
|
110
|
+
}, async ({ uuid }) => {
|
|
111
|
+
return jsonContent(await api(`/watch/${uuid}`));
|
|
112
|
+
});
|
|
113
|
+
server.registerTool("update_watch", {
|
|
114
|
+
description: "Update a watch's configuration. Use this to pause/resume, change check interval, update title, or modify notification settings.",
|
|
115
|
+
inputSchema: z.object({
|
|
116
|
+
uuid: z.string().describe("Watch UUID"),
|
|
117
|
+
title: z.string().optional().describe("New title"),
|
|
118
|
+
paused: z.boolean().optional().describe("Pause or resume monitoring"),
|
|
119
|
+
notification_muted: z.boolean().optional().describe("Mute or unmute notifications"),
|
|
120
|
+
tag: z.string().optional().describe("Tag UUID"),
|
|
121
|
+
check_hours: z.number().optional().describe("Hours between checks"),
|
|
122
|
+
check_minutes: z.number().optional().describe("Minutes between checks"),
|
|
123
|
+
url: z.string().optional().describe("New URL to monitor")
|
|
124
|
+
})
|
|
125
|
+
}, async ({ uuid, title, paused, notification_muted, tag, check_hours, check_minutes, url }) => {
|
|
126
|
+
const body = {};
|
|
127
|
+
if (title !== void 0) body.title = title;
|
|
128
|
+
if (paused !== void 0) body.paused = paused;
|
|
129
|
+
if (notification_muted !== void 0) body.notification_muted = notification_muted;
|
|
130
|
+
if (tag !== void 0) body.tag = tag;
|
|
131
|
+
if (url !== void 0) body.url = url;
|
|
132
|
+
if (check_hours !== void 0 || check_minutes !== void 0) {
|
|
133
|
+
body.time_between_check = {
|
|
134
|
+
...check_hours !== void 0 ? { hours: check_hours } : {},
|
|
135
|
+
...check_minutes !== void 0 ? { minutes: check_minutes } : {}
|
|
136
|
+
};
|
|
137
|
+
body.time_between_check_use_default = false;
|
|
138
|
+
}
|
|
139
|
+
return jsonContent(await api(`/watch/${uuid}`, {
|
|
140
|
+
method: "PUT",
|
|
141
|
+
body
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
144
|
+
server.registerTool("delete_watch", {
|
|
145
|
+
description: "Stop monitoring a URL and delete the watch with all its history.",
|
|
146
|
+
annotations: { destructiveHint: true },
|
|
147
|
+
inputSchema: z.object({ uuid: z.string().describe("Watch UUID to delete") })
|
|
148
|
+
}, async ({ uuid }) => {
|
|
149
|
+
return jsonContent(await api(`/watch/${uuid}`, { method: "DELETE" }));
|
|
150
|
+
});
|
|
151
|
+
server.registerTool("get_change_history", {
|
|
152
|
+
description: "Get the list of timestamps when changes were detected for a watch. Use these timestamps with get_snapshot or get_diff.",
|
|
153
|
+
inputSchema: z.object({ uuid: z.string().describe("Watch UUID") })
|
|
154
|
+
}, async ({ uuid }) => {
|
|
155
|
+
return jsonContent(await api(`/watch/${uuid}/history`));
|
|
156
|
+
});
|
|
157
|
+
server.registerTool("get_snapshot", {
|
|
158
|
+
description: "Get the page content captured at a specific timestamp. Use get_change_history first to find valid timestamps.",
|
|
159
|
+
inputSchema: z.object({
|
|
160
|
+
uuid: z.string().describe("Watch UUID"),
|
|
161
|
+
timestamp: z.string().describe("Timestamp from change history")
|
|
162
|
+
})
|
|
163
|
+
}, async ({ uuid, timestamp }) => {
|
|
164
|
+
return jsonContent(await api(`/watch/${uuid}/history/${timestamp}`));
|
|
165
|
+
});
|
|
166
|
+
server.registerTool("get_diff", {
|
|
167
|
+
description: "Compare page content between two timestamps to see exactly what changed. Use get_change_history to find valid timestamps.",
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
uuid: z.string().describe("Watch UUID"),
|
|
170
|
+
from_timestamp: z.string().describe("Earlier timestamp"),
|
|
171
|
+
to_timestamp: z.string().describe("Later timestamp")
|
|
172
|
+
})
|
|
173
|
+
}, async ({ uuid, from_timestamp, to_timestamp }) => {
|
|
174
|
+
return jsonContent(await api(`/watch/${uuid}/difference/${from_timestamp}/${to_timestamp}`));
|
|
175
|
+
});
|
|
176
|
+
server.registerTool("search_watches", {
|
|
177
|
+
description: "Search across all watches by URL, title, or content. Returns matching watches.",
|
|
178
|
+
inputSchema: z.object({ query: z.string().describe("Search query (matches against URL, title)") })
|
|
179
|
+
}, async ({ query }) => {
|
|
180
|
+
return jsonContent(await api("/search", { query: { q: query } }));
|
|
181
|
+
});
|
|
182
|
+
server.registerTool("list_tags", {
|
|
183
|
+
description: "List all available tags/categories for organizing watches.",
|
|
184
|
+
inputSchema: z.object({})
|
|
185
|
+
}, async () => {
|
|
186
|
+
return jsonContent(await api("/tags"));
|
|
187
|
+
});
|
|
188
|
+
server.registerTool("trigger_recheck", {
|
|
189
|
+
description: "Force an immediate recheck of all watches (or filtered by tag). Useful when you need fresh data now rather than waiting for the next scheduled check.",
|
|
190
|
+
inputSchema: z.object({ tag: z.string().optional().describe("Only recheck watches with this tag UUID") })
|
|
191
|
+
}, async ({ tag }) => {
|
|
192
|
+
const query = { recheck_all: "1" };
|
|
193
|
+
if (tag) query.tag = tag;
|
|
194
|
+
return jsonContent(await api("/watch", { query }));
|
|
195
|
+
});
|
|
196
|
+
const transport = new StdioServerTransport();
|
|
197
|
+
await server.connect(transport);
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@site-spy/mcp-server",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "MCP server for Site Spy — lets AI agents monitor websites for changes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"site-spy",
|
|
11
|
+
"website-monitoring",
|
|
12
|
+
"ai-agents"
|
|
13
|
+
],
|
|
14
|
+
"bin": {
|
|
15
|
+
"site-spy-mcp": "dist/index.mjs"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": "./dist/index.mjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown src/index.ts --format esm --banner.js '#!/usr/bin/env node'",
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
|
+
"zod": "^3.25.67"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@site-spy/tsconfig": "workspace:*",
|
|
39
|
+
"@types/node": "^25.3.0",
|
|
40
|
+
"tsdown": "^0.20.3",
|
|
41
|
+
"tsx": "^4.19.4",
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"vitest": "^4.0.18"
|
|
44
|
+
}
|
|
45
|
+
}
|