mcp-link-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/dist/index.js +217 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# mcp-link-doctor
|
|
2
|
+
|
|
3
|
+
An MCP server that analyzes websites for broken links, missing meta tags, and redirect chains. Returns a structured report with a health score and fix suggestions.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Checks all links on a page for 4xx/5xx errors (up to 20 links)
|
|
8
|
+
- Detects missing meta tags: title, description, og:title, og:description, og:image, canonical, viewport
|
|
9
|
+
- Traces redirect chains and flags excessive hops
|
|
10
|
+
- Returns a score (0–100) and actionable suggestions
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx mcp-link-doctor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configure in Claude Code
|
|
19
|
+
|
|
20
|
+
Add to your `.claude/settings.json` (or `~/.claude/settings.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"mcp-link-doctor": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["mcp-link-doctor"],
|
|
28
|
+
"env": {
|
|
29
|
+
"MCPCAT_PROJECT_ID": "proj_your_id_here"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or if running locally from the built output:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"mcp-link-doctor": {
|
|
42
|
+
"command": "node",
|
|
43
|
+
"args": ["/path/to/mcp-link-doctor/dist/index.js"],
|
|
44
|
+
"env": {
|
|
45
|
+
"MCPCAT_PROJECT_ID": "proj_your_id_here"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`MCPCAT_PROJECT_ID` is optional. Get your project ID from [mcpcat.io](https://mcpcat.io). The server runs fine without it — analytics are simply disabled.
|
|
53
|
+
|
|
54
|
+
## Tool
|
|
55
|
+
|
|
56
|
+
### `check-links`
|
|
57
|
+
|
|
58
|
+
**Input:** `{ "url": "https://example.com" }`
|
|
59
|
+
|
|
60
|
+
**Output:**
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"url": "https://example.com",
|
|
64
|
+
"brokenLinks": [{"href": "...", "status": 404, "text": "..."}],
|
|
65
|
+
"missingMetaTags": ["og:image", "canonical"],
|
|
66
|
+
"presentMetaTags": {"title": "...", "description": "..."},
|
|
67
|
+
"redirectChain": ["https://example.com -> https://www.example.com"],
|
|
68
|
+
"score": 85,
|
|
69
|
+
"suggestions": ["Add og:image meta tag", "Fix broken link (404): /old-page"]
|
|
70
|
+
}
|
|
71
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
import * as mcpcat from "mcpcat";
|
|
6
|
+
const META_TAGS_TO_CHECK = [
|
|
7
|
+
"title",
|
|
8
|
+
"description",
|
|
9
|
+
"og:title",
|
|
10
|
+
"og:description",
|
|
11
|
+
"og:image",
|
|
12
|
+
"canonical",
|
|
13
|
+
"viewport",
|
|
14
|
+
];
|
|
15
|
+
async function followRedirects(url) {
|
|
16
|
+
const chain = [url];
|
|
17
|
+
let current = url;
|
|
18
|
+
for (let i = 0; i < 10; i++) {
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(current, {
|
|
22
|
+
method: "HEAD",
|
|
23
|
+
redirect: "manual",
|
|
24
|
+
signal: AbortSignal.timeout(5000),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (res.status >= 300 && res.status < 400) {
|
|
31
|
+
const location = res.headers.get("location");
|
|
32
|
+
if (!location)
|
|
33
|
+
break;
|
|
34
|
+
const next = location.startsWith("http") ? location : new URL(location, current).href;
|
|
35
|
+
chain.push(next);
|
|
36
|
+
current = next;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { finalUrl: current, chain };
|
|
43
|
+
}
|
|
44
|
+
function extractLinks(html, baseUrl) {
|
|
45
|
+
const links = [];
|
|
46
|
+
const anchorRegex = /<a\s[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = anchorRegex.exec(html)) !== null) {
|
|
49
|
+
const rawHref = match[1].trim();
|
|
50
|
+
const rawText = match[2].replace(/<[^>]+>/g, "").trim().slice(0, 100);
|
|
51
|
+
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("mailto:") || rawHref.startsWith("javascript:")) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const href = rawHref.startsWith("http") ? rawHref : new URL(rawHref, baseUrl).href;
|
|
56
|
+
links.push({ href, text: rawText });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// skip malformed hrefs
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return links;
|
|
63
|
+
}
|
|
64
|
+
function extractMetaTags(html) {
|
|
65
|
+
const found = {};
|
|
66
|
+
// title tag
|
|
67
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
68
|
+
if (titleMatch) {
|
|
69
|
+
found["title"] = titleMatch[1].trim();
|
|
70
|
+
}
|
|
71
|
+
// <meta name="..." content="...">
|
|
72
|
+
const metaNameRegex = /<meta\s[^>]*name=["']([^"']+)["'][^>]*content=["']([^"']*)["'][^>]*>/gi;
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = metaNameRegex.exec(html)) !== null) {
|
|
75
|
+
found[m[1].toLowerCase()] = m[2];
|
|
76
|
+
}
|
|
77
|
+
// also catch content before name
|
|
78
|
+
const metaNameRegex2 = /<meta\s[^>]*content=["']([^"']*)["'][^>]*name=["']([^"']+)["'][^>]*>/gi;
|
|
79
|
+
while ((m = metaNameRegex2.exec(html)) !== null) {
|
|
80
|
+
found[m[2].toLowerCase()] = m[1];
|
|
81
|
+
}
|
|
82
|
+
// <meta property="og:..." content="...">
|
|
83
|
+
const metaPropRegex = /<meta\s[^>]*property=["']([^"']+)["'][^>]*content=["']([^"']*)["'][^>]*>/gi;
|
|
84
|
+
while ((m = metaPropRegex.exec(html)) !== null) {
|
|
85
|
+
found[m[1].toLowerCase()] = m[2];
|
|
86
|
+
}
|
|
87
|
+
// reverse order too
|
|
88
|
+
const metaPropRegex2 = /<meta\s[^>]*content=["']([^"']*)["'][^>]*property=["']([^"']+)["'][^>]*>/gi;
|
|
89
|
+
while ((m = metaPropRegex2.exec(html)) !== null) {
|
|
90
|
+
found[m[2].toLowerCase()] = m[1];
|
|
91
|
+
}
|
|
92
|
+
// <link rel="canonical" href="...">
|
|
93
|
+
const canonicalMatch = html.match(/<link\s[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>/i)
|
|
94
|
+
?? html.match(/<link\s[^>]*href=["']([^"']+)["'][^>]*rel=["']canonical["'][^>]*>/i);
|
|
95
|
+
if (canonicalMatch) {
|
|
96
|
+
found["canonical"] = canonicalMatch[1];
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
async function checkLink(href) {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(href, {
|
|
103
|
+
method: "HEAD",
|
|
104
|
+
redirect: "follow",
|
|
105
|
+
signal: AbortSignal.timeout(6000),
|
|
106
|
+
});
|
|
107
|
+
return res.status;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function computeScore(brokenLinks, missingMetaTags, redirectChainLength) {
|
|
114
|
+
let score = 100;
|
|
115
|
+
score -= brokenLinks.length * 10;
|
|
116
|
+
score -= missingMetaTags.length * 5;
|
|
117
|
+
if (redirectChainLength > 2)
|
|
118
|
+
score -= (redirectChainLength - 2) * 5;
|
|
119
|
+
return Math.max(0, score);
|
|
120
|
+
}
|
|
121
|
+
function buildSuggestions(brokenLinks, missingMetaTags, redirectChain) {
|
|
122
|
+
const suggestions = [];
|
|
123
|
+
for (const tag of missingMetaTags) {
|
|
124
|
+
suggestions.push(`Add ${tag} meta tag`);
|
|
125
|
+
}
|
|
126
|
+
for (const link of brokenLinks) {
|
|
127
|
+
suggestions.push(`Fix broken link (${link.status}): ${link.href}`);
|
|
128
|
+
}
|
|
129
|
+
if (redirectChain.length > 2) {
|
|
130
|
+
suggestions.push(`Reduce redirect chain (currently ${redirectChain.length - 1} hops): update links to point directly to ${redirectChain[redirectChain.length - 1]}`);
|
|
131
|
+
}
|
|
132
|
+
return suggestions;
|
|
133
|
+
}
|
|
134
|
+
async function analyzeUrl(url) {
|
|
135
|
+
// Follow redirects to get the final URL
|
|
136
|
+
const { finalUrl, chain } = await followRedirects(url);
|
|
137
|
+
// Fetch HTML
|
|
138
|
+
let html = "";
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(finalUrl, {
|
|
141
|
+
signal: AbortSignal.timeout(10000),
|
|
142
|
+
headers: { "User-Agent": "mcp-link-doctor/0.1.0" },
|
|
143
|
+
});
|
|
144
|
+
html = await res.text();
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(`Failed to fetch ${finalUrl}: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
// Extract and check links (limit to first 20)
|
|
150
|
+
const allLinks = extractLinks(html, finalUrl).slice(0, 20);
|
|
151
|
+
const brokenLinks = [];
|
|
152
|
+
await Promise.all(allLinks.map(async ({ href, text }) => {
|
|
153
|
+
const status = await checkLink(href);
|
|
154
|
+
if (status >= 400 || status === 0) {
|
|
155
|
+
brokenLinks.push({ href, status: status === 0 ? 0 : status, text });
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
// Extract meta tags
|
|
159
|
+
const presentMetaTags = extractMetaTags(html);
|
|
160
|
+
const missingMetaTags = META_TAGS_TO_CHECK.filter((tag) => !presentMetaTags[tag]);
|
|
161
|
+
// Format redirect chain
|
|
162
|
+
const redirectChainFormatted = chain.length > 1
|
|
163
|
+
? [chain.join(" -> ")]
|
|
164
|
+
: [];
|
|
165
|
+
const score = computeScore(brokenLinks, missingMetaTags, chain.length);
|
|
166
|
+
const suggestions = buildSuggestions(brokenLinks, missingMetaTags, chain);
|
|
167
|
+
return {
|
|
168
|
+
url,
|
|
169
|
+
brokenLinks,
|
|
170
|
+
missingMetaTags,
|
|
171
|
+
presentMetaTags,
|
|
172
|
+
redirectChain: redirectChainFormatted,
|
|
173
|
+
score,
|
|
174
|
+
suggestions,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const server = new McpServer({
|
|
178
|
+
name: "mcp-link-doctor",
|
|
179
|
+
version: "0.1.0",
|
|
180
|
+
});
|
|
181
|
+
// MCPcat analytics — replace MCPCAT_PROJECT_ID with your project ID from mcpcat.io
|
|
182
|
+
const MCPCAT_PROJECT_ID = process.env["MCPCAT_PROJECT_ID"] ?? undefined;
|
|
183
|
+
if (MCPCAT_PROJECT_ID)
|
|
184
|
+
mcpcat.track(server, MCPCAT_PROJECT_ID);
|
|
185
|
+
server.tool("check-links", "Find broken links, missing meta tags, and redirect chains on any website. Returns a structured report with fix suggestions.", { url: z.string().describe("The URL of the website to analyze") }, async ({ url }) => {
|
|
186
|
+
let report;
|
|
187
|
+
try {
|
|
188
|
+
report = await analyzeUrl(url);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
content: [
|
|
193
|
+
{
|
|
194
|
+
type: "text",
|
|
195
|
+
text: JSON.stringify({ error: String(err) }, null, 2),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
isError: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text",
|
|
205
|
+
text: JSON.stringify(report, null, 2),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
async function main() {
|
|
211
|
+
const transport = new StdioServerTransport();
|
|
212
|
+
await server.connect(transport);
|
|
213
|
+
}
|
|
214
|
+
main().catch((error) => {
|
|
215
|
+
console.error("Server failed to start:", error);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-link-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that finds broken links, missing meta tags, and redirect chains on any website. Returns a structured report with fix suggestions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-link-doctor": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["dist"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["mcp", "mcp-server", "broken-links", "seo", "link-checker", "meta-tags", "website-audit", "redirect-chain"],
|
|
15
|
+
"author": "Timothy Cai",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/linesatuni/agentic-waitlist.git",
|
|
20
|
+
"directory": "servers/mcp-link-doctor"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
24
|
+
"mcpcat": "^0.1.15",
|
|
25
|
+
"zod": "^4.3.6"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.5.2",
|
|
29
|
+
"typescript": "^6.0.2"
|
|
30
|
+
}
|
|
31
|
+
}
|