@j0hanz/fetch-url-mcp 1.0.0 → 1.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/dist/resources.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
3
- import { get as getCacheEntry, keys as listCacheKeys, parseCachedPayload, parseCacheKey, resolveCachedPayloadContent, } from './cache.js';
2
+ import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { get as getCacheEntry, keys as listCacheKeys, onCacheUpdate, parseCachedPayload, parseCacheKey, resolveCachedPayloadContent, } from './cache.js';
4
+ import { logWarn } from './observability.js';
5
+ import { isObject } from './type-guards.js';
4
6
  const CACHE_RESOURCE_TEMPLATE_URI = 'internal://cache/{namespace}/{hash}';
5
7
  const CACHE_RESOURCE_PREFIX = 'internal://cache/';
6
8
  const CACHE_NAMESPACE_PATTERN = /^[a-z0-9_-]{1,64}$/i;
@@ -34,8 +36,8 @@ function firstVariableValue(value) {
34
36
  return undefined;
35
37
  }
36
38
  function parseCacheResourceFromVariables(variables) {
37
- const namespace = firstVariableValue(variables.namespace);
38
- const hash = firstVariableValue(variables.hash);
39
+ const namespace = firstVariableValue(variables['namespace']);
40
+ const hash = firstVariableValue(variables['hash']);
39
41
  if (!namespace || !hash)
40
42
  return null;
41
43
  const decoded = {
@@ -86,7 +88,7 @@ function completeCacheNamespaces(value) {
86
88
  }
87
89
  function completeCacheHashes(value, context) {
88
90
  const normalized = value.trim().toLowerCase();
89
- const namespace = context?.arguments?.namespace?.trim();
91
+ const namespace = context?.arguments?.['namespace']?.trim();
90
92
  const hashes = new Set();
91
93
  for (const key of listCacheKeys()) {
92
94
  const parsed = parseCacheKey(key);
@@ -106,7 +108,6 @@ function listCacheResources() {
106
108
  const resources = listCacheKeys()
107
109
  .map((key) => parseCacheKey(key))
108
110
  .filter((parts) => Boolean(parts))
109
- .slice(0, MAX_COMPLETION_VALUES)
110
111
  .map((parts) => {
111
112
  const cacheParts = {
112
113
  namespace: parts.namespace,
@@ -126,6 +127,85 @@ function listCacheResources() {
126
127
  });
127
128
  return { resources };
128
129
  }
130
+ function normalizeSubscriptionUri(uri) {
131
+ if (!URL.canParse(uri)) {
132
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
133
+ }
134
+ const parsedUri = new URL(uri);
135
+ const cacheParts = parseCacheResourceFromUri(parsedUri);
136
+ if (cacheParts)
137
+ return toCacheResourceUri(cacheParts);
138
+ return parsedUri.href;
139
+ }
140
+ function registerCacheResourceNotifications(server) {
141
+ const subscribedResourceUris = new Set();
142
+ server.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
143
+ subscribedResourceUris.add(normalizeSubscriptionUri(request.params.uri));
144
+ return Promise.resolve({});
145
+ });
146
+ server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
147
+ subscribedResourceUris.delete(normalizeSubscriptionUri(request.params.uri));
148
+ return Promise.resolve({});
149
+ });
150
+ const unsubscribe = onCacheUpdate((event) => {
151
+ const changedUri = toCacheResourceUri({
152
+ namespace: event.namespace,
153
+ hash: event.urlHash,
154
+ });
155
+ if (server.isConnected() && subscribedResourceUris.has(changedUri)) {
156
+ void server.server
157
+ .sendResourceUpdated({ uri: changedUri })
158
+ .catch((error) => {
159
+ logWarn('Failed to send resource updated notification', {
160
+ uri: changedUri,
161
+ error,
162
+ });
163
+ });
164
+ }
165
+ if (!event.listChanged)
166
+ return;
167
+ if (!server.isConnected())
168
+ return;
169
+ try {
170
+ server.sendResourceListChanged();
171
+ }
172
+ catch (error) {
173
+ logWarn('Failed to send resources list changed notification', { error });
174
+ }
175
+ });
176
+ let cleanedUp = false;
177
+ const cleanup = () => {
178
+ if (cleanedUp)
179
+ return;
180
+ cleanedUp = true;
181
+ unsubscribe();
182
+ };
183
+ const originalOnClose = server.server.onclose;
184
+ server.server.onclose = () => {
185
+ cleanup();
186
+ originalOnClose?.();
187
+ };
188
+ const originalClose = server.close.bind(server);
189
+ server.close = async () => {
190
+ cleanup();
191
+ await originalClose();
192
+ };
193
+ }
194
+ function normalizeTemplateVariables(variables) {
195
+ if (!isObject(variables))
196
+ return {};
197
+ const normalized = {};
198
+ for (const [key, value] of Object.entries(variables)) {
199
+ if (typeof value === 'string' || value === undefined) {
200
+ normalized[key] = value;
201
+ continue;
202
+ }
203
+ if (Array.isArray(value)) {
204
+ normalized[key] = value.filter((item) => typeof item === 'string');
205
+ }
206
+ }
207
+ return normalized;
208
+ }
129
209
  function resolveCacheResourceParts(uri, variables) {
130
210
  const fromVariables = parseCacheResourceFromVariables(variables);
131
211
  if (fromVariables)
@@ -212,5 +292,6 @@ export function registerCacheResourceTemplate(server, iconInfo) {
212
292
  ],
213
293
  }
214
294
  : {}),
215
- }, (uri, variables) => readCacheResource(uri, variables));
295
+ }, (uri, variables) => readCacheResource(uri, normalizeTemplateVariables(variables)));
296
+ registerCacheResourceNotifications(server);
216
297
  }
package/dist/server.js CHANGED
@@ -42,7 +42,10 @@ catch (error) {
42
42
  function createServerCapabilities() {
43
43
  return {
44
44
  logging: {},
45
- resources: {},
45
+ resources: {
46
+ subscribe: true,
47
+ listChanged: true,
48
+ },
46
49
  tools: {},
47
50
  prompts: {},
48
51
  completions: {},
package/dist/session.js CHANGED
@@ -56,12 +56,15 @@ class SessionCleanupLoop {
56
56
  for await (const getNow of ticks) {
57
57
  const now = getNow();
58
58
  const evicted = this.store.evictExpired();
59
- for (const session of evicted) {
60
- unregisterMcpSessionServerByServer(session.server);
61
- void Promise.allSettled([
62
- session.transport.close(),
63
- session.server.close(),
64
- ]).then((results) => {
59
+ const closeBatchSize = 10;
60
+ for (let i = 0; i < evicted.length; i += closeBatchSize) {
61
+ const batch = evicted.slice(i, i + closeBatchSize);
62
+ await Promise.allSettled(batch.map(async (session) => {
63
+ unregisterMcpSessionServerByServer(session.server);
64
+ const results = await Promise.allSettled([
65
+ session.transport.close(),
66
+ session.server.close(),
67
+ ]);
65
68
  const [transportResult, serverResult] = results;
66
69
  if (transportResult.status === 'rejected') {
67
70
  logWarn('Failed to close expired session transport', {
@@ -73,7 +76,9 @@ class SessionCleanupLoop {
73
76
  error: formatError(serverResult.reason),
74
77
  });
75
78
  }
76
- });
79
+ }));
80
+ if (signal.aborted)
81
+ return;
77
82
  }
78
83
  if (evicted.length > 0) {
79
84
  logInfo('Expired sessions evicted', {
package/dist/tools.js CHANGED
@@ -53,6 +53,11 @@ const fetchUrlOutputSchema = z.strictObject({
53
53
  .max(config.constants.maxUrlLength)
54
54
  .optional()
55
55
  .describe('The final response URL after redirects'),
56
+ cacheResourceUri: z
57
+ .string()
58
+ .max(config.constants.maxUrlLength)
59
+ .optional()
60
+ .describe('Internal cache resource URI for retrieving full markdown via resources/read'),
56
61
  title: z.string().max(512).optional().describe('Page title'),
57
62
  metadata: z
58
63
  .strictObject({
@@ -390,7 +395,7 @@ function applyInlineContentLimit(content, inlineLimitOverride) {
390
395
  return inlineLimiter.apply(content, inlineLimitOverride);
391
396
  }
392
397
  /* -------------------------------------------------------------------------------------------------
393
- * Tool response blocks (text only)
398
+ * Tool response blocks (text + optional embedded resource)
394
399
  * ------------------------------------------------------------------------------------------------- */
395
400
  function buildTextBlock(structuredContent) {
396
401
  return {
@@ -398,8 +403,46 @@ function buildTextBlock(structuredContent) {
398
403
  text: JSON.stringify(structuredContent),
399
404
  };
400
405
  }
401
- function buildToolContentBlocks(structuredContent) {
402
- return [buildTextBlock(structuredContent)];
406
+ function buildEmbeddedResource(content, url, title) {
407
+ if (!content)
408
+ return null;
409
+ const filename = cache.generateSafeFilename(url, title, undefined, '.md');
410
+ const uri = new URL(filename, 'file:///').href;
411
+ const resource = {
412
+ uri,
413
+ mimeType: 'text/markdown',
414
+ text: content,
415
+ };
416
+ return {
417
+ type: 'resource',
418
+ resource,
419
+ };
420
+ }
421
+ function buildCacheResourceLink(cacheResourceUri, contentSize, fetchedAt) {
422
+ return {
423
+ type: 'resource_link',
424
+ uri: cacheResourceUri,
425
+ name: 'cached-markdown',
426
+ title: 'Cached Fetch Output',
427
+ description: 'Read full markdown via resources/read.',
428
+ mimeType: 'text/markdown',
429
+ ...(contentSize > 0 ? { size: contentSize } : {}),
430
+ annotations: {
431
+ audience: ['assistant'],
432
+ priority: 0.8,
433
+ lastModified: fetchedAt,
434
+ },
435
+ };
436
+ }
437
+ function buildToolContentBlocks(structuredContent, resourceLink, embeddedResource) {
438
+ const blocks = [buildTextBlock(structuredContent)];
439
+ if (resourceLink) {
440
+ blocks.push(resourceLink);
441
+ }
442
+ if (embeddedResource) {
443
+ blocks.push(embeddedResource);
444
+ }
445
+ return blocks;
403
446
  }
404
447
  function resolveNormalizedUrl(url) {
405
448
  const { normalizedUrl: validatedUrl } = normalizeUrl(url);
@@ -415,7 +458,7 @@ function logRawUrlTransformation(resolvedUrl) {
415
458
  }
416
459
  function extractTitle(value) {
417
460
  const record = asRecord(value);
418
- const title = record ? record.title : undefined;
461
+ const title = record ? record['title'] : undefined;
419
462
  return typeof title === 'string' ? title : undefined;
420
463
  }
421
464
  function logCacheMiss(reason, cacheNamespace, normalizedUrl, error) {
@@ -663,6 +706,7 @@ function serializeMarkdownResult(result) {
663
706
  * fetch-url tool implementation
664
707
  * ------------------------------------------------------------------------------------------------- */
665
708
  function buildStructuredContent(pipeline, inlineResult, inputUrl) {
709
+ const cacheResourceUri = resolveCacheResourceUri(pipeline.cacheKey);
666
710
  const truncated = inlineResult.truncated ?? pipeline.data.truncated;
667
711
  let markdown = inlineResult.content;
668
712
  if (pipeline.data.truncated &&
@@ -675,6 +719,7 @@ function buildStructuredContent(pipeline, inlineResult, inputUrl) {
675
719
  url: pipeline.originalUrl ?? pipeline.url,
676
720
  resolvedUrl: pipeline.url,
677
721
  ...(pipeline.finalUrl ? { finalUrl: pipeline.finalUrl } : {}),
722
+ ...(cacheResourceUri ? { cacheResourceUri } : {}),
678
723
  inputUrl,
679
724
  title: pipeline.data.title,
680
725
  ...(metadata ? { metadata } : {}),
@@ -685,12 +730,34 @@ function buildStructuredContent(pipeline, inlineResult, inputUrl) {
685
730
  ...(truncated ? { truncated: true } : {}),
686
731
  };
687
732
  }
688
- function buildFetchUrlContentBlocks(structuredContent) {
689
- return buildToolContentBlocks(structuredContent);
733
+ function resolveCacheResourceUri(cacheKey) {
734
+ if (!cacheKey)
735
+ return undefined;
736
+ if (!cache.isEnabled())
737
+ return undefined;
738
+ if (!cache.get(cacheKey))
739
+ return undefined;
740
+ const parsed = cache.parseCacheKey(cacheKey);
741
+ if (!parsed)
742
+ return undefined;
743
+ return `internal://cache/${encodeURIComponent(parsed.namespace)}/${encodeURIComponent(parsed.urlHash)}`;
744
+ }
745
+ function buildFetchUrlContentBlocks(structuredContent, pipeline, inlineResult) {
746
+ const cacheResourceUri = readString(structuredContent, 'cacheResourceUri');
747
+ const contentToEmbed = config.runtime.httpMode
748
+ ? inlineResult.content
749
+ : pipeline.data.content;
750
+ const resourceLink = cacheResourceUri
751
+ ? buildCacheResourceLink(cacheResourceUri, inlineResult.contentSize, pipeline.fetchedAt)
752
+ : null;
753
+ const embedded = contentToEmbed && pipeline.url
754
+ ? buildEmbeddedResource(contentToEmbed, pipeline.url, pipeline.data.title)
755
+ : null;
756
+ return buildToolContentBlocks(structuredContent, resourceLink, embedded);
690
757
  }
691
758
  function buildResponse(pipeline, inlineResult, inputUrl) {
692
759
  const structuredContent = buildStructuredContent(pipeline, inlineResult, inputUrl);
693
- const content = buildFetchUrlContentBlocks(structuredContent);
760
+ const content = buildFetchUrlContentBlocks(structuredContent, pipeline, inlineResult);
694
761
  // Runtime validation guard: verify output matches schema
695
762
  const validation = fetchUrlOutputSchema.safeParse(structuredContent);
696
763
  if (!validation.success) {
package/dist/transform.js CHANGED
@@ -44,7 +44,7 @@ function getTagName(node) {
44
44
  }
45
45
  function getAbortReason(signal) {
46
46
  const record = isObject(signal) ? signal : null;
47
- return record && 'reason' in record ? record.reason : undefined;
47
+ return record && 'reason' in record ? record['reason'] : undefined;
48
48
  }
49
49
  function isTimeoutAbortReason(reason) {
50
50
  return reason instanceof Error && reason.name === 'TimeoutError';
@@ -424,6 +424,35 @@ function isReadabilityCompatible(doc) {
424
424
  'function' &&
425
425
  typeof record.querySelector === 'function');
426
426
  }
427
+ function resolveCollapsedTextLengthUpTo(text, max) {
428
+ if (max <= 0)
429
+ return 0;
430
+ let length = 0;
431
+ let seenNonWhitespace = false;
432
+ let pendingSpace = false;
433
+ for (let i = 0; i < text.length; i += 1) {
434
+ const code = text.charCodeAt(i);
435
+ const isWhitespace = code <= 0x20;
436
+ if (isWhitespace) {
437
+ if (seenNonWhitespace)
438
+ pendingSpace = true;
439
+ continue;
440
+ }
441
+ if (!seenNonWhitespace) {
442
+ seenNonWhitespace = true;
443
+ }
444
+ else if (pendingSpace) {
445
+ length += 1;
446
+ pendingSpace = false;
447
+ if (length >= max)
448
+ return length;
449
+ }
450
+ length += 1;
451
+ if (length >= max)
452
+ return length;
453
+ }
454
+ return length;
455
+ }
427
456
  function extractArticle(document, url, signal) {
428
457
  if (!isReadabilityCompatible(document)) {
429
458
  logWarn('Document not compatible with Readability');
@@ -436,7 +465,7 @@ function extractArticle(document, url, signal) {
436
465
  const rawText = doc.querySelector('body')?.textContent ??
437
466
  doc.documentElement.textContent ??
438
467
  '';
439
- const textLength = rawText.replace(/\s+/g, ' ').trim().length;
468
+ const textLength = resolveCollapsedTextLengthUpTo(rawText, 401);
440
469
  if (textLength < 100) {
441
470
  logWarn('Very minimal server-rendered content detected (< 100 chars). ' +
442
471
  'This might be a client-side rendered (SPA) application. ' +
@@ -1610,13 +1639,13 @@ function isWorkerErrorPayload(value) {
1610
1639
  function isWorkerResponse(raw) {
1611
1640
  if (!isObject(raw))
1612
1641
  return false;
1613
- if (typeof raw.id !== 'string')
1642
+ if (typeof raw['id'] !== 'string')
1614
1643
  return false;
1615
- if (raw.type === 'result') {
1616
- return isWorkerResultPayload(raw.result);
1644
+ if (raw['type'] === 'result') {
1645
+ return isWorkerResultPayload(raw['result']);
1617
1646
  }
1618
- if (raw.type === 'error') {
1619
- return isWorkerErrorPayload(raw.error);
1647
+ if (raw['type'] === 'error') {
1648
+ return isWorkerErrorPayload(raw['error']);
1620
1649
  }
1621
1650
  return false;
1622
1651
  }
@@ -2287,7 +2316,7 @@ async function transformWithWorkerPool(htmlOrBuffer, url, options) {
2287
2316
  });
2288
2317
  }
2289
2318
  function resolveWorkerFallback(error, htmlOrBuffer, url, options) {
2290
- const isQueueFull = error instanceof FetchError && error.details.reason === 'queue_full';
2319
+ const isQueueFull = error instanceof FetchError && error.details['reason'] === 'queue_full';
2291
2320
  if (isQueueFull) {
2292
2321
  logWarn('Transform worker queue full; falling back to in-process', {
2293
2322
  url: redactUrl(url),
@@ -122,15 +122,16 @@ process.on('message', (raw) => {
122
122
  if (!raw || typeof raw !== 'object')
123
123
  return;
124
124
  const msg = raw;
125
- if (msg.type === 'cancel') {
126
- if (typeof msg.id !== 'string')
125
+ const { type, id } = msg;
126
+ if (type === 'cancel') {
127
+ if (typeof id !== 'string')
127
128
  return;
128
- const controller = controllersById.get(msg.id);
129
+ const controller = controllersById.get(id);
129
130
  if (controller)
130
131
  controller.abort(new Error('Canceled'));
131
132
  return;
132
133
  }
133
- if (msg.type === 'transform') {
134
+ if (type === 'transform') {
134
135
  handleTransform(msg);
135
136
  }
136
137
  });
@@ -114,15 +114,16 @@ port.on('message', (raw) => {
114
114
  if (!raw || typeof raw !== 'object')
115
115
  return;
116
116
  const msg = raw;
117
- if (msg.type === 'cancel') {
118
- if (typeof msg.id !== 'string')
117
+ const { type, id } = msg;
118
+ if (type === 'cancel') {
119
+ if (typeof id !== 'string')
119
120
  return;
120
- const controller = controllersById.get(msg.id);
121
+ const controller = controllersById.get(id);
121
122
  if (controller)
122
123
  controller.abort(new Error('Canceled'));
123
124
  return;
124
125
  }
125
- if (msg.type === 'transform') {
126
+ if (type === 'transform') {
126
127
  handleTransform(msg);
127
128
  }
128
129
  });
package/package.json CHANGED
@@ -1,91 +1,91 @@
1
- {
2
- "name": "@j0hanz/fetch-url-mcp",
3
- "version": "1.0.0",
4
- "mcpName": "io.github.j0hanz/fetch-url-mcp",
5
- "description": "Intelligent web content fetcher MCP server that converts HTML to clean, AI-readable Markdown",
6
- "type": "module",
7
- "main": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "bin": {
10
- "fetch-url-mcp": "dist/index.js"
11
- },
12
- "exports": {
13
- ".": {
14
- "types": "./dist/index.d.ts",
15
- "default": "./dist/index.js"
16
- },
17
- "./package.json": "./package.json"
18
- },
19
- "files": [
20
- "dist",
21
- "README.md"
22
- ],
23
- "repository": {
24
- "type": "git",
25
- "url": "https://github.com/j0hanz/fetch-url-mcp.git"
26
- },
27
- "homepage": "https://github.com/j0hanz/fetch-url-mcp#readme",
28
- "bugs": {
29
- "url": "https://github.com/j0hanz/fetch-url-mcp/issues"
30
- },
31
- "author": "j0hanz",
32
- "license": "MIT",
33
- "keywords": [
34
- "mcp",
35
- "mcp-server",
36
- "web-fetching",
37
- "content-extraction",
38
- "readability",
39
- "markdown",
40
- "ai-tools",
41
- "model-context-protocol",
42
- "fetch-url-mcp"
43
- ],
44
- "scripts": {
45
- "clean": "node scripts/tasks.mjs clean",
46
- "validate:instructions": "node scripts/tasks.mjs validate:instructions",
47
- "build": "node scripts/tasks.mjs build",
48
- "copy:assets": "node scripts/tasks.mjs copy:assets",
49
- "prepare": "npm run build",
50
- "dev": "tsc --watch --preserveWatchOutput",
51
- "dev:run": "node --env-file=.env --watch dist/index.js",
52
- "start": "node dist/index.js",
53
- "format": "prettier --write .",
54
- "type-check": "node scripts/tasks.mjs type-check",
55
- "type-check:diagnostics": "tsc --noEmit --extendedDiagnostics",
56
- "type-check:trace": "node -e \"require('fs').rmSync('.ts-trace',{recursive:true,force:true})\" && tsc --noEmit --generateTrace .ts-trace",
57
- "lint": "eslint .",
58
- "lint:fix": "eslint . --fix",
59
- "test": "node scripts/tasks.mjs test",
60
- "test:coverage": "node scripts/tasks.mjs test --coverage",
61
- "knip": "knip",
62
- "knip:fix": "knip --fix",
63
- "inspector": "npm run build && npx -y @modelcontextprotocol/inspector node dist/index.js --stdio",
64
- "prepublishOnly": "npm run lint && npm run type-check && npm run build"
65
- },
66
- "dependencies": {
67
- "@modelcontextprotocol/sdk": "^1.26.0",
68
- "@mozilla/readability": "^0.6.0",
69
- "linkedom": "^0.18.12",
70
- "node-html-markdown": "^2.0.0",
71
- "zod": "^4.3.6"
72
- },
73
- "devDependencies": {
74
- "@eslint/js": "^9.39.2",
75
- "@trivago/prettier-plugin-sort-imports": "^6.0.2",
76
- "@types/node": "^24",
77
- "eslint": "^9.23.2",
78
- "eslint-config-prettier": "^10.1.8",
79
- "eslint-plugin-de-morgan": "^2.0.0",
80
- "eslint-plugin-depend": "^1.4.0",
81
- "eslint-plugin-sonarjs": "^3.0.6",
82
- "eslint-plugin-unused-imports": "^4.4.1",
83
- "knip": "^5.83.1",
84
- "prettier": "^3.8.1",
85
- "typescript": "^5.9.3",
86
- "typescript-eslint": "^8.55.0"
87
- },
88
- "engines": {
89
- "node": ">=24"
90
- }
91
- }
1
+ {
2
+ "name": "@j0hanz/fetch-url-mcp",
3
+ "version": "1.1.0",
4
+ "mcpName": "io.github.j0hanz/fetch-url-mcp",
5
+ "description": "Intelligent web content fetcher MCP server that converts HTML to clean, AI-readable Markdown",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "fetch-url-mcp": "dist/index.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/j0hanz/fetch-url-mcp.git"
26
+ },
27
+ "homepage": "https://github.com/j0hanz/fetch-url-mcp#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/j0hanz/fetch-url-mcp/issues"
30
+ },
31
+ "author": "j0hanz",
32
+ "license": "MIT",
33
+ "keywords": [
34
+ "mcp",
35
+ "mcp-server",
36
+ "web-fetching",
37
+ "content-extraction",
38
+ "readability",
39
+ "markdown",
40
+ "ai-tools",
41
+ "model-context-protocol",
42
+ "fetch-url-mcp"
43
+ ],
44
+ "scripts": {
45
+ "clean": "node scripts/tasks.mjs clean",
46
+ "validate:instructions": "node scripts/tasks.mjs validate:instructions",
47
+ "build": "node scripts/tasks.mjs build",
48
+ "copy:assets": "node scripts/tasks.mjs copy:assets",
49
+ "prepare": "npm run build",
50
+ "dev": "tsc --watch --preserveWatchOutput",
51
+ "dev:run": "node --env-file=.env --watch dist/index.js",
52
+ "start": "node dist/index.js",
53
+ "format": "prettier --write .",
54
+ "type-check": "node scripts/tasks.mjs type-check",
55
+ "type-check:diagnostics": "tsc --noEmit --extendedDiagnostics",
56
+ "type-check:trace": "node -e \"require('fs').rmSync('.ts-trace',{recursive:true,force:true})\" && tsc --noEmit --generateTrace .ts-trace",
57
+ "lint": "eslint .",
58
+ "lint:fix": "eslint . --fix",
59
+ "test": "node scripts/tasks.mjs test",
60
+ "test:coverage": "node scripts/tasks.mjs test --coverage",
61
+ "knip": "knip",
62
+ "knip:fix": "knip --fix",
63
+ "inspector": "npm run build && npx -y @modelcontextprotocol/inspector node dist/index.js --stdio",
64
+ "prepublishOnly": "npm run lint && npm run type-check && npm run build"
65
+ },
66
+ "dependencies": {
67
+ "@modelcontextprotocol/sdk": "^1.26.0",
68
+ "@mozilla/readability": "^0.6.0",
69
+ "linkedom": "^0.18.12",
70
+ "node-html-markdown": "^2.0.0",
71
+ "zod": "^4.3.6"
72
+ },
73
+ "devDependencies": {
74
+ "@eslint/js": "^9.39.2",
75
+ "@trivago/prettier-plugin-sort-imports": "^6.0.2",
76
+ "@types/node": "^24",
77
+ "eslint": "^9.23.2",
78
+ "eslint-config-prettier": "^10.1.8",
79
+ "eslint-plugin-de-morgan": "^2.0.0",
80
+ "eslint-plugin-depend": "^1.4.0",
81
+ "eslint-plugin-sonarjs": "^3.0.6",
82
+ "eslint-plugin-unused-imports": "^4.4.1",
83
+ "knip": "^5.83.1",
84
+ "prettier": "^3.8.1",
85
+ "typescript": "^5.9.3",
86
+ "typescript-eslint": "^8.55.0"
87
+ },
88
+ "engines": {
89
+ "node": ">=24"
90
+ }
91
+ }