# Directory Structure
```
├── .gitignore
├── .whitesource
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ └── postbuild.js
├── smithery.yaml
├── src
│ └── index.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | /build
2 | /node_modules
```
--------------------------------------------------------------------------------
/.whitesource:
--------------------------------------------------------------------------------
```
1 | {
2 | "scanSettings": {
3 | "baseBranches": []
4 | },
5 | "checkRunSettings": {
6 | "vulnerableCheckRunConclusionLevel": "failure",
7 | "displayMode": "diff",
8 | "useMendCheckNames": true
9 | },
10 | "issueSettings": {
11 | "minSeverityLevel": "LOW",
12 | "issueType": "DEPENDENCY"
13 | }
14 | }
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | [](https://mseep.ai/app/dazeb-markdown-downloader)
2 | [](https://mseep.ai/app/dazeb-markdown-downloader)
3 |
4 | [](https://mseep.ai/app/e85a9805-464e-46bd-a953-ccac0c4a5129)
5 |
6 | # Markdown Downloader MCP Server
7 |
8 | [](https://smithery.ai/server/@dazeb/markdown-downloader)
9 |
10 | ## Overview
11 |
12 | Markdown Downloader is a powerful MCP (Model Context Protocol) server that allows you to download webpages as markdown files with ease. Leveraging the r.jina.ai service, this tool provides a seamless way to convert web content into markdown format.
13 |
14 | <a href="https://glama.ai/mcp/servers/jrki7zltg7">
15 | <img width="380" height="200" src="https://glama.ai/mcp/servers/jrki7zltg7/badge" alt="Markdown Downloader MCP server" />
16 | </a>
17 |
18 | ## Features
19 |
20 | - 🌐 Download webpages as markdown using r.jina.ai
21 | - 📁 Configurable download directory
22 | - 📝 Automatically generates date-stamped filenames
23 | - 🔍 List downloaded markdown files
24 | - 💾 Persistent configuration
25 |
26 | ## Prerequisites
27 |
28 | - Node.js (version 16 or higher)
29 | - npm (Node Package Manager)
30 |
31 | ## Installation
32 |
33 | ### Installing via Smithery
34 |
35 | To install Markdown Downloader for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@dazeb/markdown-downloader):
36 |
37 | ```bash
38 | npx -y @smithery/cli install @dazeb/markdown-downloader --client claude
39 | ```
40 |
41 | ### Installing manually
42 |
43 | 1. Clone the repository:
44 | ```bash
45 | git clone https://github.com/your-username/markdown-downloader.git
46 | cd markdown-downloader
47 | ```
48 |
49 | 2. Install dependencies:
50 | ```bash
51 | npm install
52 | ```
53 |
54 | 3. Build the project:
55 | ```bash
56 | npm run build
57 | ```
58 |
59 | ## Manually Add Server to Cline/Roo-Cline MCP Settings file
60 |
61 | ### Linux/macOS
62 | ```json
63 | {
64 | "mcpServers": {
65 | "markdown-downloader": {
66 | "command": "node",
67 | "args": [
68 | "/home/user/Documents/Cline/MCP/markdown-downloader/build/index.js"
69 | ],
70 | "disabled": false,
71 | "alwaysAllow": [
72 | "download_markdown",
73 | "set_download_directory"
74 | ]
75 | }
76 | }
77 | }
78 | ```
79 |
80 | ### Windows
81 | ```json
82 | {
83 | "mcpServers": {
84 | "markdown-downloader": {
85 | "command": "node",
86 | "args": [
87 | "C:\\Users\\username\\Documents\\Cline\\MCP\\markdown-downloader\\build\\index.js"
88 | ],
89 | "disabled": false,
90 | "alwaysAllow": [
91 | "download_markdown",
92 | "set_download_directory"
93 | ]
94 | }
95 | }
96 | }
97 | ```
98 |
99 | ## Tools and Usage
100 |
101 | ### 1. Set Download Directory
102 |
103 | Change the download directory:
104 |
105 | ```bash
106 | use set_download_directory /path/to/your/local/download/folder
107 | ```
108 |
109 | - Validates directory exists and is writable
110 | - Persists the configuration for future use
111 |
112 | ### 2. Download Markdown
113 |
114 | Download a webpage as a markdown file:
115 |
116 | ```bash
117 | use tool download_markdown https://example.com/blog-post
118 | ```
119 |
120 | - The URL will be prepended with `r.jina.ai`
121 | - Filename format: `{sanitized-url}-{date}.md`
122 | - Saved in the configured download directory
123 |
124 | ### 3. List Downloaded Files
125 |
126 | List all downloaded markdown files:
127 |
128 | ```bash
129 | use list_downloaded_files
130 | ```
131 |
132 | ### 4. Get Download Directory
133 |
134 | Retrieve the current download directory:
135 |
136 | ```bash
137 | use get_download_directory
138 | ```
139 |
140 | ## Configuration
141 |
142 | ### Linux/macOS
143 | - Configuration is stored in `~/.config/markdown-downloader/config.json`
144 | - Default download directory: `~/.markdown-downloads`
145 |
146 | ### Windows
147 | - Configuration is stored in `%APPDATA%\markdown-downloader\config.json`
148 | - Default download directory: `%USERPROFILE%\Documents\markdown-downloads`
149 |
150 | ## Troubleshooting
151 |
152 | - Ensure you have an active internet connection
153 | - Check that the URL is valid and accessible
154 | - Verify write permissions for the download directory
155 |
156 | ## Security
157 |
158 | - The tool uses r.jina.ai to fetch markdown content
159 | - Local files are saved with sanitized filenames
160 | - Configurable download directory allows flexibility
161 |
162 | ## Contributing
163 |
164 | Contributions are welcome! Please feel free to submit a Pull Request.
165 |
166 | ## License
167 |
168 | This project is licensed under the MIT License. See the LICENSE file for details.
169 |
170 | ## Disclaimer
171 |
172 | This tool is provided as-is. Always review downloaded content for accuracy and appropriateness.
173 |
174 | ## Support
175 |
176 | For issues or feature requests, please open an issue on the GitHub repository.
177 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "resolveJsonModule": true,
11 | "outDir": "./build"
12 | },
13 | "include": ["src/**/*"]
14 | }
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "markdown-downloader",
3 | "version": "1.0.0",
4 | "type": "module",
5 | "scripts": {
6 | "build": "tsc && node scripts/postbuild.js",
7 | "start": "node build/index.js"
8 | },
9 | "dependencies": {
10 | "@modelcontextprotocol/sdk": "^1.0.4",
11 | "axios": "^1.8.3",
12 | "fs-extra": "^11.2.0"
13 | },
14 | "devDependencies": {
15 | "@types/fs-extra": "^11.0.4",
16 | "@types/node": "^20.17.10",
17 | "typescript": "^5.3.2"
18 | }
19 | }
20 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | {}
8 | commandFunction:
9 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
10 | |-
11 | (config) => ({
12 | command: 'node',
13 | args: ['build/index.js']
14 | })
15 | exampleConfig: {}
16 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | FROM node:lts-alpine
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Copy package files
8 | COPY package*.json ./
9 |
10 | # Install dependencies
11 | RUN npm install --ignore-scripts
12 |
13 | # Copy source code and other necessary files
14 | COPY . .
15 |
16 | # Build the project
17 | RUN npm run build
18 |
19 | # Expose port if needed (optional)
20 | # EXPOSE 3000
21 |
22 | # Command to run the MCP server
23 | CMD [ "node", "build/index.js" ]
24 |
```
--------------------------------------------------------------------------------
/scripts/postbuild.js:
--------------------------------------------------------------------------------
```javascript
1 | // Cross-platform post-build script
2 | import fs from 'fs';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = path.dirname(__filename);
8 | const buildIndexPath = path.join(__dirname, '..', 'build', 'index.js');
9 |
10 | // Only set executable permissions on Unix-like systems
11 | if (process.platform !== 'win32') {
12 | try {
13 | fs.chmodSync(buildIndexPath, '755');
14 | console.log('Set executable permissions on build/index.js');
15 | } catch (error) {
16 | console.error('Error setting executable permissions:', error);
17 | }
18 | } else {
19 | console.log('Skipping chmod on Windows platform');
20 | }
21 |
22 | console.log('Post-build tasks completed');
23 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.1.0] - 2025-04-XX
9 |
10 | ### Added
11 | - Windows platform support
12 | - Cross-platform configuration paths
13 | - Platform-specific default download directories
14 | - Updated documentation with Windows-specific instructions
15 |
16 | ### Changed
17 | - Replaced Unix-specific build script with cross-platform version
18 | - Improved environment variable handling using Node.js os module
19 | - Updated README with platform-specific configuration information
20 |
21 | ### Security
22 | - Updated axios from 1.7.9 to 1.8.3 to fix CVE-2025-27152 (High severity)
23 |
24 | ## [1.0.0] - 2025-XX-XX
25 |
26 | ### Added
27 | - Initial release
28 | - Download webpages as markdown using r.jina.ai
29 | - Configurable download directory
30 | - List downloaded markdown files
31 | - Create subdirectories for organizing downloads
32 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import {
5 | CallToolRequestSchema,
6 | ErrorCode,
7 | ListToolsRequestSchema,
8 | McpError,
9 | } from '@modelcontextprotocol/sdk/types.js';
10 | import axios from 'axios';
11 | import fs from 'fs-extra';
12 | import path from 'path';
13 | import os from 'os';
14 |
15 | // Configuration management
16 | // Use platform-specific paths for configuration
17 | const homedir = os.homedir();
18 | const configBasePath = process.platform === 'win32'
19 | ? path.join(process.env.APPDATA || homedir, 'markdown-downloader')
20 | : path.join(homedir, '.config', 'markdown-downloader');
21 | const CONFIG_DIR = configBasePath;
22 | const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
23 |
24 | // Default download directory based on platform
25 | const getDefaultDownloadDir = () => {
26 | return process.platform === 'win32'
27 | ? path.join(homedir, 'Documents', 'markdown-downloads')
28 | : path.join(homedir, '.markdown-downloads');
29 | };
30 |
31 | interface MarkdownDownloaderConfig {
32 | downloadDirectory: string;
33 | }
34 |
35 | function getConfig(): MarkdownDownloaderConfig {
36 | try {
37 | fs.ensureDirSync(CONFIG_DIR);
38 | if (!fs.existsSync(CONFIG_FILE)) {
39 | // Default to platform-specific directory if no config exists
40 | const defaultDownloadDir = getDefaultDownloadDir();
41 | const defaultConfig: MarkdownDownloaderConfig = {
42 | downloadDirectory: defaultDownloadDir
43 | };
44 | fs.writeJsonSync(CONFIG_FILE, defaultConfig);
45 | fs.ensureDirSync(defaultConfig.downloadDirectory);
46 | return defaultConfig;
47 | }
48 | return fs.readJsonSync(CONFIG_FILE);
49 | } catch (error) {
50 | console.error('Error reading config:', error);
51 | // Fallback to default
52 | const defaultDownloadDir = getDefaultDownloadDir();
53 | return {
54 | downloadDirectory: defaultDownloadDir
55 | };
56 | }
57 | }
58 |
59 | function saveConfig(config: MarkdownDownloaderConfig) {
60 | try {
61 | fs.ensureDirSync(CONFIG_DIR);
62 | fs.writeJsonSync(CONFIG_FILE, config);
63 | fs.ensureDirSync(config.downloadDirectory);
64 | } catch (error) {
65 | console.error('Error saving config:', error);
66 | }
67 | }
68 |
69 | function sanitizeFilename(url: string): string {
70 | // Remove protocol, replace non-alphanumeric chars with dash
71 | return url
72 | .replace(/^https?:\/\//, '')
73 | .replace(/[^a-z0-9]/gi, '-')
74 | .toLowerCase();
75 | }
76 |
77 | function generateFilename(url: string): string {
78 | const sanitizedUrl = sanitizeFilename(url);
79 | const datestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
80 | return `${sanitizedUrl}-${datestamp}.md`;
81 | }
82 |
83 | class MarkdownDownloaderServer {
84 | private server: Server;
85 |
86 | constructor() {
87 | this.server = new Server(
88 | {
89 | name: 'markdown-downloader',
90 | version: '1.0.0',
91 | },
92 | {
93 | capabilities: {
94 | resources: {},
95 | tools: {},
96 | },
97 | }
98 | );
99 |
100 | this.setupToolHandlers();
101 |
102 | // Error handling
103 | this.server.onerror = (serverError: unknown) => console.error('[MCP Error]', serverError);
104 | process.on('SIGINT', async () => {
105 | await this.server.close();
106 | process.exit(0);
107 | });
108 | }
109 |
110 | private setupToolHandlers(): void {
111 | // List available tools
112 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
113 | tools: [
114 | {
115 | name: 'download_markdown',
116 | description: 'Download a webpage as markdown using r.jina.ai',
117 | inputSchema: {
118 | type: 'object',
119 | properties: {
120 | url: {
121 | type: 'string',
122 | description: 'URL of the webpage to download'
123 | },
124 | subdirectory: {
125 | type: 'string',
126 | description: 'Optional subdirectory to save the file in'
127 | }
128 | },
129 | required: ['url']
130 | }
131 | },
132 | {
133 | name: 'list_downloaded_files',
134 | description: 'List all downloaded markdown files',
135 | inputSchema: {
136 | type: 'object',
137 | properties: {
138 | subdirectory: {
139 | type: 'string',
140 | description: 'Optional subdirectory to list files from'
141 | }
142 | }
143 | }
144 | },
145 | {
146 | name: 'set_download_directory',
147 | description: 'Set the main local download folder for markdown files',
148 | inputSchema: {
149 | type: 'object',
150 | properties: {
151 | directory: {
152 | type: 'string',
153 | description: 'Full path to the download directory'
154 | }
155 | },
156 | required: ['directory']
157 | }
158 | },
159 | {
160 | name: 'get_download_directory',
161 | description: 'Get the current download directory',
162 | inputSchema: {
163 | type: 'object',
164 | properties: {}
165 | }
166 | },
167 | {
168 | name: 'create_subdirectory',
169 | description: 'Create a new subdirectory in the root download folder',
170 | inputSchema: {
171 | type: 'object',
172 | properties: {
173 | name: {
174 | type: 'string',
175 | description: 'Name of the subdirectory to create'
176 | }
177 | },
178 | required: ['name']
179 | }
180 | }
181 | ]
182 | }));
183 |
184 | // Tool to download markdown
185 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
186 | // Download markdown
187 | if (request.params.name === 'download_markdown') {
188 | const url = request.params.arguments?.url;
189 | const subdirectory = request.params.arguments?.subdirectory;
190 |
191 | if (!url || typeof url !== 'string') {
192 | throw new McpError(
193 | ErrorCode.InvalidParams,
194 | 'A valid URL must be provided'
195 | );
196 | }
197 |
198 | try {
199 | // Get current download directory
200 | const config = getConfig();
201 |
202 | // Prepend r.jina.ai to the URL
203 | const jinaUrl = `https://r.jina.ai/${url}`;
204 |
205 | // Download markdown
206 | const response = await axios.get(jinaUrl, {
207 | headers: {
208 | 'Accept': 'text/markdown'
209 | }
210 | });
211 |
212 | // Generate filename
213 | const filename = generateFilename(url);
214 | let filepath = path.join(config.downloadDirectory, filename);
215 |
216 | // If subdirectory is specified, use it
217 | if (subdirectory && typeof subdirectory === 'string') {
218 | filepath = path.join(config.downloadDirectory, subdirectory, filename);
219 | fs.ensureDirSync(path.dirname(filepath));
220 | }
221 |
222 | // Save markdown file
223 | await fs.writeFile(filepath, response.data);
224 |
225 | return {
226 | content: [
227 | {
228 | type: 'text',
229 | text: `Markdown downloaded and saved as ${filename} in ${path.dirname(filepath)}`
230 | }
231 | ]
232 | };
233 | } catch (downloadError) {
234 | console.error('Download error:', downloadError);
235 | return {
236 | content: [
237 | {
238 | type: 'text',
239 | text: `Failed to download markdown: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}`
240 | }
241 | ],
242 | isError: true
243 | };
244 | }
245 | }
246 |
247 | // List downloaded files
248 | if (request.params.name === 'list_downloaded_files') {
249 | try {
250 | const config = getConfig();
251 | const subdirectory = request.params.arguments?.subdirectory;
252 | const listDir = subdirectory && typeof subdirectory === 'string'
253 | ? path.join(config.downloadDirectory, subdirectory)
254 | : config.downloadDirectory;
255 | const files = await fs.readdir(listDir);
256 | return {
257 | content: [
258 | {
259 | type: 'text',
260 | text: files.join('\n')
261 | }
262 | ]
263 | };
264 | } catch (listError) {
265 | const errorMessage = listError instanceof Error ? listError.message : 'Unknown error';
266 | return {
267 | content: [
268 | {
269 | type: 'text',
270 | text: `Failed to list files: ${errorMessage}`
271 | }
272 | ],
273 | isError: true
274 | };
275 | }
276 | }
277 |
278 | // Set download directory
279 | if (request.params.name === 'set_download_directory') {
280 | const directory = request.params.arguments?.directory;
281 |
282 | if (!directory || typeof directory !== 'string') {
283 | throw new McpError(
284 | ErrorCode.InvalidParams,
285 | 'A valid directory path must be provided'
286 | );
287 | }
288 |
289 | try {
290 | // Validate directory exists and is writable
291 | await fs.access(directory, fs.constants.W_OK);
292 |
293 | // Update and save config
294 | const config = getConfig();
295 | config.downloadDirectory = directory;
296 | saveConfig(config);
297 |
298 | return {
299 | content: [
300 | {
301 | type: 'text',
302 | text: `Download directory set to: ${directory}`
303 | }
304 | ]
305 | };
306 | } catch (error) {
307 | const errorMessage = error instanceof Error ? error.message : 'Unknown error';
308 | return {
309 | content: [
310 | {
311 | type: 'text',
312 | text: `Failed to set download directory: ${errorMessage}`
313 | }
314 | ],
315 | isError: true
316 | };
317 | }
318 | }
319 |
320 | // Get download directory
321 | if (request.params.name === 'get_download_directory') {
322 | const config = getConfig();
323 | return {
324 | content: [
325 | {
326 | type: 'text',
327 | text: config.downloadDirectory
328 | }
329 | ]
330 | };
331 | }
332 |
333 | // Create subdirectory
334 | if (request.params.name === 'create_subdirectory') {
335 | const subdirectoryName = request.params.arguments?.name;
336 |
337 | if (!subdirectoryName || typeof subdirectoryName !== 'string') {
338 | throw new McpError(
339 | ErrorCode.InvalidParams,
340 | 'A valid subdirectory name must be provided'
341 | );
342 | }
343 |
344 | try {
345 | const config = getConfig();
346 | const newSubdirectoryPath = path.join(config.downloadDirectory, subdirectoryName);
347 |
348 | // Create the subdirectory
349 | await fs.ensureDir(newSubdirectoryPath);
350 |
351 | return {
352 | content: [
353 | {
354 | type: 'text',
355 | text: `Subdirectory created: ${newSubdirectoryPath}`
356 | }
357 | ]
358 | };
359 | } catch (error) {
360 | const errorMessage = error instanceof Error ? error.message : 'Unknown error';
361 | return {
362 | content: [
363 | {
364 | type: 'text',
365 | text: `Failed to create subdirectory: ${errorMessage}`
366 | }
367 | ],
368 | isError: true
369 | };
370 | }
371 | }
372 |
373 | throw new McpError(
374 | ErrorCode.MethodNotFound,
375 | `Unknown tool: ${request.params.name}`
376 | );
377 | });
378 | }
379 |
380 | async run(): Promise<void> {
381 | const transport = new StdioServerTransport();
382 | await this.server.connect(transport);
383 | console.error('Markdown Downloader MCP server running on stdio');
384 | }
385 | }
386 |
387 | const server = new MarkdownDownloaderServer();
388 | server.run().catch((error: Error) => console.error('Server error:', error));
```