Add MCP server for AI agents

Expose webshare_search, webshare_get_link, and webshare_login_check over
stdio via the Model Context Protocol SDK. Document client configuration.
This commit is contained in:
2026-08-07 08:50:10 +02:00
parent c2b5563c25
commit f7f1481a39
7 changed files with 929 additions and 20 deletions

153
src/mcp-server.js Normal file
View File

@@ -0,0 +1,153 @@
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { z } = require('zod');
const { WebshareClient } = require('./webshare');
function formatSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
/**
* Build an MCP server that exposes Webshare search and link resolution tools.
* @param {{ username: string, password: string }} credentials
*/
function createWebshareMcpServer({ username, password }) {
const client = new WebshareClient(username, password);
const server = new McpServer({
name: 'webshare-api',
version: '1.0.0',
});
server.registerTool(
'webshare_search',
{
title: 'Search Webshare',
description:
'Search Webshare.cz for files (default category: video). Returns ident, name, size, and votes for each hit. Use ident with webshare_get_link to resolve a download URL.',
inputSchema: {
query: z.string().min(1).describe('Search query (title, SxxExx, etc.)'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Max results (1-100, default 25)'),
offset: z
.number()
.int()
.min(0)
.optional()
.describe('Result offset for pagination (default 0)'),
sort: z
.enum(['rating', 'recent', 'largest', 'smallest'])
.optional()
.describe('Sort order (default rating)'),
category: z
.string()
.optional()
.describe('Webshare category filter (default video)'),
},
},
async ({ query, limit, offset, sort, category }) => {
try {
await client.ensureAuth();
const results = await client.search(query, {
limit: limit || 25,
offset: offset || 0,
sort: sort || 'rating',
category: category || 'video',
});
const lines = results.map(
(f, i) =>
`${i + 1}. ${f.name}\n ident: ${f.ident}\n size: ${formatSize(f.size)} (${f.size} bytes)\n votes: ${f.votes}`
);
const text =
results.length === 0
? `No results for query: ${query}`
: `Found ${results.length} result(s) for "${query}":\n\n${lines.join('\n\n')}`;
return {
content: [{ type: 'text', text }],
structuredContent: { query, count: results.length, results },
};
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `webshare_search failed: ${err.message}` }],
};
}
}
);
server.registerTool(
'webshare_get_link',
{
title: 'Resolve Webshare download link',
description:
'Resolve a temporary HTTPS CDN download/stream URL for a Webshare file ident. Links expire after a short time (~10 minutes). Requires an authenticated Webshare account.',
inputSchema: {
ident: z
.string()
.min(1)
.describe('Webshare file ident from webshare_search results'),
},
},
async ({ ident }) => {
try {
const link = await client.getFileLink(ident);
return {
content: [
{
type: 'text',
text: `Resolved link for ident ${ident} (expires soon):\n${link}`,
},
],
structuredContent: { ident, link },
};
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `webshare_get_link failed: ${err.message}` }],
};
}
}
);
server.registerTool(
'webshare_login_check',
{
title: 'Check Webshare authentication',
description:
'Verify that WEBSHARE_USERNAME / WEBSHARE_PASSWORD work by performing a login against the Webshare API.',
inputSchema: {},
},
async () => {
try {
await client.ensureAuth();
return {
content: [{ type: 'text', text: 'Webshare authentication OK.' }],
structuredContent: { ok: true },
};
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `Authentication failed: ${err.message}` }],
structuredContent: { ok: false, error: err.message },
};
}
}
);
return server;
}
module.exports = { createWebshareMcpServer, formatSize };

32
src/mcp.js Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* MCP stdio entrypoint for AI agents.
*
* Configure clients with:
* command: node
* args: ["…/webshare-api/src/mcp.js"]
* env: { WEBSHARE_USERNAME, WEBSHARE_PASSWORD }
*
* Do not write application logs to stdout — it is reserved for the MCP protocol.
*/
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const { createWebshareMcpServer } = require('./mcp-server');
async function main() {
const username = process.env.WEBSHARE_USERNAME;
const password = process.env.WEBSHARE_PASSWORD;
if (!username || !password) {
console.error('WEBSHARE_USERNAME and WEBSHARE_PASSWORD must be set');
process.exit(1);
}
const server = createWebshareMcpServer({ username, password });
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('webshare-api MCP server running on stdio');
}
main().catch((err) => {
console.error('MCP server failed:', err);
process.exit(1);
});

View File

@@ -34,7 +34,8 @@ class WebshareClient {
});
if (r2.status !== 'OK') throw new Error(`login() failed: ${r2.message}`);
this.wst = r2.token;
console.log('Webshare: authenticated');
// stderr only — MCP stdio must keep stdout clean
console.error('Webshare: authenticated');
}
async ensureAuth() {
@@ -43,8 +44,8 @@ class WebshareClient {
await this._loginPromise;
}
async search(query, { limit = 30, offset = 0, sort = 'rating' } = {}) {
const data = { what: query, category: 'video', sort, limit, offset };
async search(query, { limit = 30, offset = 0, sort = 'rating', category = 'video' } = {}) {
const data = { what: query, category, sort, limit, offset };
if (this.wst) data.wst = this.wst;
const r = await post('search', data);
if (!r.file) return [];