@operstack/mcp 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OperStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @operstack/mcp
2
+
3
+ Let Claude, Cursor or any MCP client measure a site the way a search engine and an AI actually read it.
4
+
5
+ Ask your assistant *"why does ChatGPT recommend my competitor and not me?"* and it can now go and look: read the site, score six areas, follow the map the site offers to machines, and tell you what is actually missing. Free public signals only. No account, no API key, nothing stored.
6
+
7
+ ## Install
8
+
9
+ In Claude Desktop, add this to `claude_desktop_config.json`:
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "operstack": {
15
+ "command": "npx",
16
+ "args": ["-y", "@operstack/mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ In Cursor, add the same block to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project.
23
+
24
+ Node 20 or newer. Nothing else to install.
25
+
26
+ ## What it can do
27
+
28
+ **`audit_site`** reads a public site and scores six areas out of ten: technical SEO, content and structure, AEO (whether an answer engine can quote it), GEO (whether AI systems can identify and use it), off-page trust, and conversion. Returns every failing and borderline check with what was actually found on the pages, so the assistant can explain rather than guess.
29
+
30
+ **`check_llms_txt`** reads the site's `/llms.txt`, checks it against the format, and follows its links to see whether each one still leads to a page, a redirect or nothing. This file rots silently, because no human ever opens it.
31
+
32
+ **`compare_sites`** runs the same measurement on two to four sites and returns them side by side. Useful when the question is not *"am I bad"* but *"am I worse than them, and where"*.
33
+
34
+ **`run_gates`** runs the sixteen [OperStack content gates](https://www.npmjs.com/package/@operstack/gates) on a folder of Markdown or MDX on your own machine: cut titles, copied paragraphs, hollow sections, dead links, redirect chains, a stale agent index, figures with no source, and the agent surface. Files are read locally and sent nowhere.
35
+
36
+ ## What it will not do
37
+
38
+ It will not invent a number. Where something was not measured it says so and why. It will not score a site on data that costs money: every figure comes from what any stranger can read on the site for nothing, which means you can reproduce it yourself. It will not report on a site that did not answer: a typo in the address gets a refusal, not a page of findings about nothing.
39
+
40
+ It keeps nothing. The address you check is used for the request and forgotten. There is no telemetry, no cache and no account.
41
+
42
+ ## Why the numbers match the paid report
43
+
44
+ Every measurement here runs through [`@operstack/audit`](https://www.npmjs.com/package/@operstack/audit), the same package behind the paid OperStack reports. A free tool that disagrees with the paid one is the fastest way to lose the reader, so they are the same code.
45
+
46
+ ## Related
47
+
48
+ - [Sixteen content gates](https://www.npmjs.com/package/@operstack/gates), free, MIT
49
+ - [Astro starter](https://github.com/oper-stack/astro-starter) that already passes them, free, MIT
50
+ - [Free llms.txt checker in the browser](https://oper-stack.com/tools/llms-txt-checker/)
51
+ - [Free AI visibility check](https://oper-stack.com/ai-visibility/)
52
+
53
+ MIT.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /** Точка входа MCP-сервера OperStack. Общение идёт по stdio, поэтому сюда нельзя печатать
3
+ * ничего своего: любая посторонняя строка в stdout ломает протокол. */
4
+ import { run } from '../src/server.mjs';
5
+
6
+ run().catch((e) => {
7
+ process.stderr.write(`operstack-mcp failed to start: ${e.message}\n`);
8
+ process.exit(1);
9
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@operstack/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that lets Claude, Cursor and any MCP client measure a site the way a search engine and an AI read it: AI visibility score, llms.txt health, the sixteen content gates, and a full public-signal audit. Free data only, no account, nothing stored.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "OperStack",
8
+ "homepage": "https://oper-stack.com/products/mcp/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/oper-stack/mcp-server.git"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "seo",
17
+ "aeo",
18
+ "geo",
19
+ "llms-txt",
20
+ "ai-visibility",
21
+ "claude",
22
+ "cursor",
23
+ "site-audit"
24
+ ],
25
+ "bin": {
26
+ "operstack-mcp": "bin/operstack-mcp.mjs"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "scripts": {
38
+ "start": "node bin/operstack-mcp.mjs",
39
+ "test": "node src/test-tools.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.0.0",
43
+ "@operstack/audit": "^0.13.1",
44
+ "zod": "^4.6.2"
45
+ }
46
+ }
package/src/llms.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Проверка llms.txt: тот же разбор, что на oper-stack.com, только без браузера.
3
+ *
4
+ * Простым языком: llms.txt это список ваших страниц, написанный для программ. Ассистент читает его
5
+ * вместо того, чтобы обходить весь сайт. Файл ставят один раз и забывают, а сайт живёт дальше, и
6
+ * через полгода половина ссылок ведёт в никуда. Человек этого не замечает, потому что этот адрес не
7
+ * открывает никто.
8
+ */
9
+ const UA = 'Mozilla/5.0 (compatible; OperStackMCP/0.1; +https://oper-stack.com/products/mcp/)';
10
+
11
+ const LINK_RE = /^\s*[-*]\s*\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*:?\s*(.*)$/;
12
+ const BARE_RE = /^\s*[-*]\s*(https?:\/\/\S+|\/\S+)\s*(?:[:—–-]\s*(.*))?$/;
13
+
14
+ export function parseLlms(text) {
15
+ const lines = String(text || '').split(/\r?\n/);
16
+ let title = null; let summary = null;
17
+ const sections = []; const links = []; let bare = 0;
18
+ for (const line of lines) {
19
+ const h1 = /^#\s+(.+)$/.exec(line);
20
+ if (h1 && !title) { title = h1[1].trim(); continue; }
21
+ const h2 = /^##\s+(.+)$/.exec(line);
22
+ if (h2) { sections.push(h2[1].trim()); continue; }
23
+ const q = /^>\s*(.+)$/.exec(line);
24
+ if (q && !summary) { summary = q[1].trim(); continue; }
25
+ const m = LINK_RE.exec(line);
26
+ if (m) { links.push({ title: m[1].trim(), url: m[2].trim() }); continue; }
27
+ const b = BARE_RE.exec(line);
28
+ if (b) { bare++; links.push({ title: (b[2] || '').trim() || '(no title)', url: b[1].trim() }); }
29
+ }
30
+ return { title, summary, sections, links, bare };
31
+ }
32
+
33
+ export function normaliseSite(raw) {
34
+ const s = String(raw || '').trim();
35
+ if (!s) throw new Error('give the address of a site');
36
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(s);
37
+ if (scheme && !/^https?$/i.test(scheme[1])) throw new Error('only http and https addresses work');
38
+ const u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`);
39
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(u.hostname)) throw new Error(`that does not look like a domain name: ${u.hostname}`);
40
+ // Зарезервированные имена ловим и с конца: internal.localhost и box.local проходят проверку на
41
+ // домен, но всегда указывают внутрь машины или сети.
42
+ if (/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.|\[)/i.test(u.hostname)
43
+ || /\.(localhost|local|internal|test|example|invalid|home|lan|intranet)$/i.test(u.hostname)) {
44
+ throw new Error('a local address is not reachable from the outside');
45
+ }
46
+ return u.origin;
47
+ }
48
+
49
+ async function head(url, ms) {
50
+ try {
51
+ const r = await fetch(url, { method: 'HEAD', headers: { 'user-agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(ms) });
52
+ if (r.status === 405 || r.status === 501) {
53
+ const g = await fetch(url, { headers: { 'user-agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(ms) });
54
+ return { status: g.status, location: g.headers.get('location') || undefined };
55
+ }
56
+ return { status: r.status, location: r.headers.get('location') || undefined };
57
+ } catch { return { status: null }; }
58
+ }
59
+
60
+ export async function checkLlms(site, { maxLinks = 20, timeoutMs = 8000 } = {}) {
61
+ const origin = normaliseSite(site);
62
+ const url = `${origin}/llms.txt`;
63
+ let text = null; let status = null;
64
+ try {
65
+ const r = await fetch(url, { headers: { 'user-agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
66
+ status = r.status;
67
+ if (r.ok) text = await r.text();
68
+ } catch { /* нет ответа */ }
69
+
70
+ if (text === null) {
71
+ return {
72
+ site: origin, url, found: false, status,
73
+ verdict: 'missing',
74
+ findings: [{ level: 'fail', message: status ? `no file at ${url}: the server answered ${status}` : `${url} did not answer` }],
75
+ links: [],
76
+ };
77
+ }
78
+
79
+ const p = parseLlms(text);
80
+ const findings = [];
81
+ if (!p.title) findings.push({ level: 'fail', message: 'no H1 naming the site on the first line, which the format requires' });
82
+ else findings.push({ level: 'ok', message: `names the site: ${p.title}` });
83
+ if (!p.summary) findings.push({ level: 'warn', message: 'no blockquote summary under the heading' });
84
+ if (!p.sections.length) findings.push({ level: 'warn', message: 'no sections grouping the links' });
85
+ if (!p.links.length) findings.push({ level: 'fail', message: 'lists no links at all' });
86
+ else if (p.bare === p.links.length) findings.push({ level: 'warn', message: `all ${p.links.length} links are bare addresses; the format asks for [title](address) so a reader knows what is behind each one` });
87
+ else if (p.bare) findings.push({ level: 'warn', message: `${p.bare} of ${p.links.length} links are bare addresses` });
88
+
89
+ const sample = p.links.slice(0, maxLinks);
90
+ const checked = await Promise.all(sample.map(async (l) => {
91
+ let abs = l.url;
92
+ try { abs = new URL(l.url, `${origin}/`).href; } catch { /* как есть */ }
93
+ const r = await head(abs, Math.min(4000, timeoutMs));
94
+ return { title: l.title, url: abs, status: r.status, ...(r.location ? { redirectsTo: new URL(r.location, abs).href } : {}) };
95
+ }));
96
+ const dead = checked.filter((l) => l.status === null || l.status >= 400);
97
+ const moved = checked.filter((l) => l.status !== null && l.status >= 300 && l.status < 400);
98
+ if (dead.length) findings.push({ level: 'fail', message: `${dead.length} of ${checked.length} checked links lead nowhere` });
99
+ if (moved.length) findings.push({ level: 'warn', message: `${moved.length} of ${checked.length} checked links redirect` });
100
+ if (checked.length && !dead.length && !moved.length) findings.push({ level: 'ok', message: `all ${checked.length} checked links answer directly` });
101
+
102
+ let full = false;
103
+ try { full = (await fetch(`${origin}/llms-full.txt`, { method: 'HEAD', headers: { 'user-agent': UA }, signal: AbortSignal.timeout(3000) })).ok; } catch { /* нет */ }
104
+ if (full) findings.push({ level: 'ok', message: 'llms-full.txt is there too' });
105
+
106
+ const fails = findings.filter((f) => f.level === 'fail').length;
107
+ const warns = findings.filter((f) => f.level === 'warn').length;
108
+ return {
109
+ site: origin, url, found: true, bytes: text.length,
110
+ title: p.title, summary: p.summary, sections: p.sections,
111
+ linksListed: p.links.length, linksChecked: checked.length, hasFullText: full,
112
+ verdict: fails ? 'broken' : warns ? 'needs work' : 'good',
113
+ findings, links: checked,
114
+ };
115
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * MCP-сервер OperStack.
3
+ *
4
+ * Простым языком. Это надстройка, которая даёт Claude, Cursor и любому другому клиенту MCP
5
+ * измерить сайт так, как его читают поисковик и ИИ: сколько текста они видят, есть ли карта для
6
+ * агентов, куда ведут ссылки в ней, и что мешает вас процитировать. Вы спрашиваете ассистента
7
+ * обычными словами, он сам зовёт нужную проверку и отвечает по её результатам.
8
+ *
9
+ * Что важно: ничего не хранится, аккаунт не нужен, и ни одна цифра не берётся из платного сервиса.
10
+ * Всё, что здесь считается, посчитано из того, что любой посторонний читает на сайте бесплатно.
11
+ *
12
+ * Технически: stdio-сервер на официальном SDK. Инструменты тонкие, вся работа в @operstack/audit,
13
+ * том же пакете, на котором сделаны платные отчёты. Поэтому цифра, которую увидит здесь человек,
14
+ * совпадает с цифрой в отчёте, за который платят: расхождение между бесплатным и платным
15
+ * инструментом это худший способ потерять доверие.
16
+ */
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { z } from 'zod';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync } from 'node:fs';
22
+ import { collect, localiseChecks, AREAS_RU } from '@operstack/audit';
23
+ import { checkLlms, normaliseSite } from './llms.mjs';
24
+
25
+ export const VERSION = '0.1.0';
26
+
27
+ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
28
+ const fail = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
29
+
30
+ /** Компактная сводка аудита: клиенту не нужен весь JSON на сорок проверок, ему нужен вывод. */
31
+ function summarise(audit, lang) {
32
+ const ru = lang === 'ru';
33
+ const checks = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
34
+ const areaName = (k) => (ru ? (AREAS_RU[k] || k) : k);
35
+ const open = checks.filter((c) => c.status === 'bad' || c.status === 'warn');
36
+ return {
37
+ site: audit.meta?.site,
38
+ reachable: audit.meta?.reachable !== false,
39
+ collectedAt: audit.meta?.collectedAt,
40
+ pagesSampled: (audit.sample || []).filter((p) => p.title !== undefined).length,
41
+ scores: Object.fromEntries(Object.entries(audit.scores || {}).map(([k, v]) => [areaName(k), v ?? (ru ? 'не измерялось' : 'not measured')])),
42
+ failing: checks.filter((c) => c.status === 'bad').map((c) => ({ check: c.label, found: c.value })),
43
+ needsAttention: checks.filter((c) => c.status === 'warn').map((c) => ({ check: c.label, found: c.value })),
44
+ passing: checks.filter((c) => c.status === 'ok').length,
45
+ openCount: open.length,
46
+ note: ru
47
+ ? 'Каждая цифра посчитана из того, что любой посторонний читает на сайте бесплатно. Ничего не сохранено.'
48
+ : 'Every figure is computed from what any stranger can read on the site for nothing. Nothing was stored.',
49
+ };
50
+ }
51
+
52
+ export function buildServer() {
53
+ const server = new McpServer({ name: 'operstack', version: VERSION });
54
+
55
+ server.registerTool(
56
+ 'audit_site',
57
+ {
58
+ title: 'Audit a site the way a search engine and an AI read it',
59
+ description:
60
+ 'Read a public site and score six areas out of ten: technical SEO, content and structure, AEO (whether an answer engine can quote it), GEO (whether AI systems can identify and use it), off-page trust, and conversion. Returns every failing and borderline check with what was actually found on the pages. Free public signals only: no account, no paid tool, nothing stored. Use it when someone asks why a site is not being cited, why AI assistants recommend competitors, or what to fix first.',
61
+ inputSchema: {
62
+ url: z.string().describe('The site to read, for example example.com or https://example.com/'),
63
+ pages: z.number().int().min(1).max(40).optional().describe('How many pages to sample from the sitemap. Default 12; more pages take longer.'),
64
+ lang: z.enum(['en', 'ru']).optional().describe('Language of the check names and findings. Default en.'),
65
+ },
66
+ },
67
+ async ({ url, pages = 12, lang = 'en' }) => {
68
+ let site;
69
+ try { site = normaliseSite(url); } catch (e) { return fail(e.message); }
70
+ try {
71
+ const audit = await collect(site, { pages, lang, rendered: false, log: () => {} });
72
+ if (audit.meta?.reachable === false) return fail(`${site} did not answer. Check the address: a report about a site that is not there would be invented.`);
73
+ return text(summarise(audit, lang));
74
+ } catch (e) {
75
+ return fail(`could not read ${site}: ${e.message}`);
76
+ }
77
+ },
78
+ );
79
+
80
+ server.registerTool(
81
+ 'check_llms_txt',
82
+ {
83
+ title: 'Check the llms.txt map a site offers to AI',
84
+ description:
85
+ 'Read a site\'s /llms.txt, check it against the format (one H1 naming the site, a summary, sections of links) and follow its links to see whether each still leads to a page, a redirect or nothing. This file rots silently because no human ever opens it. Use it when someone asks whether their llms.txt is correct, why an assistant quotes the wrong pages, or before publishing a new one.',
86
+ inputSchema: {
87
+ url: z.string().describe('The site to check, for example example.com'),
88
+ maxLinks: z.number().int().min(1).max(50).optional().describe('How many links to follow. Default 20.'),
89
+ },
90
+ },
91
+ async ({ url, maxLinks = 20 }) => {
92
+ try { return text(await checkLlms(url, { maxLinks })); }
93
+ catch (e) { return fail(e.message); }
94
+ },
95
+ );
96
+
97
+ server.registerTool(
98
+ 'run_gates',
99
+ {
100
+ title: 'Run the sixteen OperStack content gates on a local project',
101
+ description:
102
+ 'Run the sixteen quality gates on a folder of Markdown or MDX content on this machine: cut titles, copied paragraphs, hollow sections, dead links, redirect chains, a stale agent index, figures with no source, and the agent surface. Returns the report. Use it before publishing, or when asked what is wrong with a content corpus. It needs a local path, not a URL, and it reads files without sending them anywhere.',
103
+ inputSchema: {
104
+ path: z.string().describe('Absolute path to the project folder that holds the content'),
105
+ only: z.string().optional().describe('Comma-separated gate numbers to run, for example 1,4,16. Default all.'),
106
+ },
107
+ },
108
+ async ({ path: dir, only }) => {
109
+ if (!existsSync(dir)) return fail(`no such folder: ${dir}`);
110
+ const args = ['--yes', '@operstack/gates'];
111
+ if (only) args.push('--only', only);
112
+ const r = spawnSync('npx', args, { cwd: dir, encoding: 'utf8', timeout: 10 * 60 * 1000, env: process.env });
113
+ const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
114
+ if (!out) return fail(`the gates produced no output in ${dir}. Is there a content folder there?`);
115
+ return text(out);
116
+ },
117
+ );
118
+
119
+ server.registerTool(
120
+ 'compare_sites',
121
+ {
122
+ title: 'Compare a site with its rivals on the same measurements',
123
+ description:
124
+ 'Run the same six-area measurement on two to four public sites and return them side by side. Use it when someone asks why a competitor is being quoted instead of them, or wants to know where the gap actually is rather than guessing. Free public signals only.',
125
+ inputSchema: {
126
+ urls: z.array(z.string()).min(2).max(4).describe('The sites to compare. The first one is treated as yours.'),
127
+ pages: z.number().int().min(1).max(20).optional().describe('Pages to sample per site. Default 8.'),
128
+ lang: z.enum(['en', 'ru']).optional(),
129
+ },
130
+ },
131
+ async ({ urls, pages = 8, lang = 'en' }) => {
132
+ const rows = [];
133
+ for (const raw of urls) {
134
+ let site;
135
+ try { site = normaliseSite(raw); } catch (e) { rows.push({ site: raw, error: e.message }); continue; }
136
+ try {
137
+ const audit = await collect(site, { pages, lang, rendered: false, log: () => {} });
138
+ if (audit.meta?.reachable === false) { rows.push({ site, error: 'did not answer' }); continue; }
139
+ const ru = lang === 'ru';
140
+ rows.push({
141
+ site,
142
+ scores: Object.fromEntries(Object.entries(audit.scores || {}).map(([k, v]) => [ru ? (AREAS_RU[k] || k) : k, v ?? null])),
143
+ openCount: (audit.checks || []).filter((c) => c.status !== 'ok').length,
144
+ });
145
+ } catch (e) { rows.push({ site, error: e.message }); }
146
+ }
147
+ return text({ yours: rows[0]?.site, comparison: rows, note: 'Same measurement on every site, taken from the outside. Nothing stored.' });
148
+ },
149
+ );
150
+
151
+ return server;
152
+ }
153
+
154
+ export async function run() {
155
+ const server = buildServer();
156
+ await server.connect(new StdioServerTransport());
157
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Проверка сервера так, как его увидит клиент: поднимаем настоящий процесс, соединяемся по stdio,
4
+ * спрашиваем список инструментов и зовём каждый. Проверять внутренние функции в обход протокола
5
+ * бессмысленно: сломаться может именно стык.
6
+ *
7
+ * npm test
8
+ * npm test -- --live ещё и сходить на живой сайт (медленнее, нужна сеть)
9
+ */
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { resolve, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const LIVE = process.argv.includes('--live');
17
+
18
+ let failed = 0;
19
+ const ok = (name, cond, detail = '') => {
20
+ if (cond) console.log(`ok ${name}`);
21
+ else { failed++; console.error(`FAIL ${name}${detail ? `\n ${detail}` : ''}`); }
22
+ };
23
+
24
+ const firstText = (r) => (r?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
25
+
26
+ async function main() {
27
+ const client = new Client({ name: 'operstack-test', version: '1.0.0' });
28
+ await client.connect(new StdioClientTransport({
29
+ command: process.execPath,
30
+ args: [resolve(ROOT, 'bin/operstack-mcp.mjs')],
31
+ }));
32
+
33
+ const { tools } = await client.listTools();
34
+ const names = tools.map((t) => t.name).sort();
35
+ ok('сервер отвечает списком инструментов', tools.length > 0, `получено: ${names.join(', ')}`);
36
+ ok('есть все четыре инструмента', ['audit_site', 'check_llms_txt', 'compare_sites', 'run_gates'].every((n) => names.includes(n)), names.join(', '));
37
+ ok('у каждого инструмента есть описание', tools.every((t) => (t.description || '').length > 60), tools.filter((t) => (t.description || '').length <= 60).map((t) => t.name).join(', '));
38
+ ok('у каждого инструмента есть схема входа', tools.every((t) => t.inputSchema && t.inputSchema.type === 'object'));
39
+
40
+ // Отказы: их проверяем всегда, сеть для них не нужна.
41
+ for (const bad of ['localhost', '127.0.0.1', '192.168.1.1', 'internal.localhost']) {
42
+ const r = await client.callTool({ name: 'check_llms_txt', arguments: { url: bad } });
43
+ ok(`внутренний адрес отвергнут: ${bad}`, r.isError === true && /(local address|domain name)/i.test(firstText(r)), firstText(r));
44
+ }
45
+
46
+ const scheme = await client.callTool({ name: 'audit_site', arguments: { url: 'ftp://example.com' } });
47
+ ok('чужая схема отвергнута', scheme.isError === true && /http and https/i.test(firstText(scheme)), firstText(scheme));
48
+
49
+ const nodir = await client.callTool({ name: 'run_gates', arguments: { path: '/no/such/folder/anywhere' } });
50
+ ok('несуществующая папка отвергнута', nodir.isError === true && /no such folder/i.test(firstText(nodir)), firstText(nodir));
51
+
52
+ if (LIVE) {
53
+ const llms = await client.callTool({ name: 'check_llms_txt', arguments: { url: 'oper-stack.com', maxLinks: 5 } });
54
+ const parsed = JSON.parse(firstText(llms));
55
+ ok('живая проверка llms.txt нашла файл', parsed.found === true, firstText(llms).slice(0, 200));
56
+ ok('живая проверка вернула вердикт', ['good', 'needs work', 'broken'].includes(parsed.verdict), parsed.verdict);
57
+
58
+ const audit = await client.callTool({ name: 'audit_site', arguments: { url: 'oper-stack.com', pages: 3 } });
59
+ const a = JSON.parse(firstText(audit));
60
+ ok('живой аудит вернул оценки', a.scores && Object.keys(a.scores).length === 6, JSON.stringify(a.scores));
61
+ ok('живой аудит отметил сайт достижимым', a.reachable === true);
62
+
63
+ const dead = await client.callTool({ name: 'audit_site', arguments: { url: 'this-domain-does-not-exist-operstack-test.com', pages: 2 } });
64
+ ok('мёртвый домен отвергнут, а не оценён', dead.isError === true && /did not answer/i.test(firstText(dead)), firstText(dead));
65
+ } else {
66
+ console.log('... живые проверки пропущены (запустите с --live)');
67
+ }
68
+
69
+ await client.close();
70
+ if (failed) { console.error(`\n${failed} проверок упало`); process.exit(1); }
71
+ console.log('\nсервер отвечает как положено');
72
+ }
73
+
74
+ main().catch((e) => { console.error(e); process.exit(1); });