webmaster-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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +49 -0
  5. package/dist/core/analysis/index.d.ts +43 -0
  6. package/dist/core/analysis/index.js +14 -0
  7. package/dist/core/auth/accounts.d.ts +11 -0
  8. package/dist/core/auth/accounts.js +12 -0
  9. package/dist/core/auth/prompt.d.ts +1 -0
  10. package/dist/core/auth/prompt.js +4 -0
  11. package/dist/core/auth/store.d.ts +11 -0
  12. package/dist/core/auth/store.js +22 -0
  13. package/dist/core/cache/index.d.ts +11 -0
  14. package/dist/core/cache/index.js +21 -0
  15. package/dist/core/errors.d.ts +9 -0
  16. package/dist/core/errors.js +24 -0
  17. package/dist/core/normalize/index.d.ts +4 -0
  18. package/dist/core/normalize/index.js +15 -0
  19. package/dist/core/scheduler.d.ts +18 -0
  20. package/dist/core/scheduler.js +52 -0
  21. package/dist/core/service.d.ts +30 -0
  22. package/dist/core/service.js +48 -0
  23. package/dist/core/types/index.d.ts +73 -0
  24. package/dist/core/types/index.js +1 -0
  25. package/dist/core/validation.d.ts +18 -0
  26. package/dist/core/validation.js +35 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.js +8 -0
  29. package/dist/mcp/index.d.ts +1 -0
  30. package/dist/mcp/index.js +4 -0
  31. package/dist/mcp/tools/index.d.ts +2 -0
  32. package/dist/mcp/tools/index.js +15 -0
  33. package/dist/providers/bing/index.d.ts +20 -0
  34. package/dist/providers/bing/index.js +31 -0
  35. package/dist/providers/google/auth.d.ts +10 -0
  36. package/dist/providers/google/auth.js +57 -0
  37. package/dist/providers/google/index.d.ts +22 -0
  38. package/dist/providers/google/index.js +35 -0
  39. package/dist/providers/yandex/auth.d.ts +5 -0
  40. package/dist/providers/yandex/auth.js +9 -0
  41. package/dist/providers/yandex/index.d.ts +22 -0
  42. package/dist/providers/yandex/index.js +70 -0
  43. package/dist/server/context.d.ts +10 -0
  44. package/dist/server/context.js +10 -0
  45. package/package.json +42 -0
@@ -0,0 +1,48 @@
1
+ import { normalizeError, WebmasterError } from './errors.js';
2
+ import { validateSite, validateUrl, canonicalSite, range } from './validation.js';
3
+ import { cacheKey } from './cache/index.js';
4
+ export class WebmasterService {
5
+ providers;
6
+ cache;
7
+ providerFactory;
8
+ constructor(providers, cache, providerFactory) {
9
+ this.providers = providers;
10
+ this.cache = cache;
11
+ this.providerFactory = providerFactory;
12
+ }
13
+ selected(engines, account) { return this.providers.filter(p => (!engines || engines.includes(p.id)) && (!account || p.account === account)); }
14
+ oneAccount(rows, account) { if (account)
15
+ return rows; const chosen = new Map(); for (const row of rows) {
16
+ const current = chosen.get(row.engine);
17
+ if (!current || row.account === 'default')
18
+ chosen.set(row.engine, row.account ?? 'default');
19
+ } return rows.filter(row => (row.account ?? 'default') === chosen.get(row.engine)); }
20
+ async collect(capability, fn, engines, account) { if (this.providerFactory)
21
+ this.providers.splice(0, this.providers.length, ...this.providerFactory()); const selected = this.selected(engines, account); const settled = await Promise.allSettled(selected.map(async (p) => { if (!p.supports(capability))
22
+ throw new Error('UNSUPPORTED'); if (!await p.isAuthenticated()) {
23
+ if (engines?.includes(p.id) || account)
24
+ throw new WebmasterError('AUTH_FAILED', `${p.id}/${p.account} is not connected.`);
25
+ return [];
26
+ } return fn(p); })); const results = [], errors = []; settled.forEach((item, i) => { const p = selected[i]; if (item.status === 'fulfilled')
27
+ results.push(...item.value);
28
+ else
29
+ errors.push(item.reason?.message === 'UNSUPPORTED' ? { engine: p.id, account: p.account, code: 'UNSUPPORTED', message: `${capability} is not supported by ${p.id}.` } : normalizeError(p.id, p.account, item.reason)); }); return { results, errors }; }
30
+ async sites() { const data = await this.collect('sites', p => this.cached(p, '', 'sites', {}, 3600000, () => p.getSites())); const grouped = new Map(); for (const s of data.results) {
31
+ const id = canonicalSite(s.site);
32
+ const entry = grouped.get(id) ?? { site: id, engines: [], accounts: {} };
33
+ if (!entry.engines.includes(s.engine))
34
+ entry.engines.push(s.engine);
35
+ entry.accounts[s.engine] = [...(entry.accounts[s.engine] ?? []), s.account];
36
+ grouped.set(id, entry);
37
+ } return { results: [...grouped.values()], errors: data.errors }; }
38
+ async cached(p, site, operation, params, ttl, load) { const key = cacheKey(p.id, p.account, site, operation, params); const hit = this.cache.get(key); if (hit !== null)
39
+ return hit; const value = await load(); this.cache.set(key, value, ttl); return value; }
40
+ async metrics(operation, input, engines) { range.parse(input); validateSite(input.site); const id = canonicalSite(input.site); const data = await this.collect(operation, async (p) => { const sites = await this.cached(p, '', 'sites', {}, 3600000, () => p.getSites()); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
41
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const providerInput = { ...input, site: owned.site }; const rows = await this.cached(p, owned.site, operation, providerInput, 21600000, () => p[operation === 'performance' ? 'getPerformance' : operation === 'pages' ? 'getPages' : 'getQueries'](providerInput)); return rows.map(row => ({ ...row, site: id })); }, engines, input.account); data.results = this.oneAccount(data.results, input.account); if (input.sort)
42
+ data.results.sort((a, b) => (b[input.sort] ?? 0) - (a[input.sort] ?? 0)); if (input.limit)
43
+ data.results = data.results.slice(0, input.limit); return data; }
44
+ async sitemaps(site, engines, account) { validateSite(site); const id = canonicalSite(site); const data = await this.collect('sitemaps', async (p) => { const sites = await p.getSites(); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
45
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const rows = await this.cached(p, owned.site, 'sitemaps', {}, 1800000, () => p.getSitemaps(owned.site)); return rows.map(row => ({ ...row, site: id, account: p.account })); }, engines, account); data.results = this.oneAccount(data.results, account); return data; }
46
+ async inspect(site, url, engines, account) { validateUrl(site, url); const id = canonicalSite(site); const data = await this.collect('urlInspection', async (p) => { const sites = await p.getSites(); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
47
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const row = await this.cached(p, owned.site, 'inspect_url', { url }, 300000, () => p.inspectUrl(owned.site, url)); return [{ ...row, site: id, account: p.account }]; }, engines, account); data.results = this.oneAccount(data.results, account); return data; }
48
+ }
@@ -0,0 +1,73 @@
1
+ export type Engine = 'google' | 'bing' | 'yandex';
2
+ export type Capability = 'sites' | 'performance' | 'queries' | 'pages' | 'sitemaps' | 'urlInspection' | 'urlSubmission' | 'indexing';
3
+ export interface Site {
4
+ site: string;
5
+ engine: Engine;
6
+ account: string;
7
+ permission?: string;
8
+ }
9
+ export interface SearchMetric {
10
+ engine: Engine;
11
+ site: string;
12
+ account?: string;
13
+ query?: string;
14
+ page?: string;
15
+ country?: string;
16
+ device?: string;
17
+ date?: string;
18
+ clicks?: number;
19
+ impressions?: number;
20
+ ctr?: number;
21
+ position?: number;
22
+ }
23
+ export interface PerformanceQuery {
24
+ site: string;
25
+ from: string;
26
+ to: string;
27
+ account?: string;
28
+ limit?: number;
29
+ sort?: 'clicks' | 'impressions' | 'ctr' | 'position';
30
+ }
31
+ export interface Sitemap {
32
+ engine: Engine;
33
+ site: string;
34
+ url: string;
35
+ account?: string;
36
+ submitted?: string;
37
+ status?: string;
38
+ }
39
+ export type IndexState = 'indexed' | 'not_indexed' | 'unknown';
40
+ export interface UrlInspection {
41
+ engine: Engine;
42
+ site: string;
43
+ url: string;
44
+ account?: string;
45
+ state: IndexState;
46
+ reason?: string;
47
+ inspectedAt?: string;
48
+ }
49
+ export interface ProviderError {
50
+ engine: Engine;
51
+ account: string;
52
+ code: 'AUTH_FAILED' | 'PERMISSION_DENIED' | 'RATE_LIMITED' | 'UNSUPPORTED' | 'INVALID_INPUT' | 'UPSTREAM_ERROR' | 'NETWORK_ERROR';
53
+ message: string;
54
+ }
55
+ export interface PartialResult<T> {
56
+ results: T[];
57
+ errors: ProviderError[];
58
+ warnings?: string[];
59
+ }
60
+ export interface WebmasterProvider {
61
+ readonly id: Engine;
62
+ readonly account: string;
63
+ supports(capability: Capability): boolean;
64
+ authenticate(): Promise<void>;
65
+ isAuthenticated(): Promise<boolean>;
66
+ getSites(): Promise<Site[]>;
67
+ getPerformance(input: PerformanceQuery): Promise<SearchMetric[]>;
68
+ getPages(input: PerformanceQuery): Promise<SearchMetric[]>;
69
+ getQueries(input: PerformanceQuery): Promise<SearchMetric[]>;
70
+ getSitemaps?(site: string): Promise<Sitemap[]>;
71
+ inspectUrl?(site: string, url: string): Promise<UrlInspection>;
72
+ submitUrl?(site: string, url: string): Promise<void>;
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ export declare const date: z.ZodISODate;
3
+ export declare const range: z.ZodObject<{
4
+ site: z.ZodString;
5
+ from: z.ZodISODate;
6
+ to: z.ZodISODate;
7
+ account: z.ZodOptional<z.ZodString>;
8
+ limit: z.ZodOptional<z.ZodNumber>;
9
+ sort: z.ZodOptional<z.ZodEnum<{
10
+ clicks: "clicks";
11
+ ctr: "ctr";
12
+ impressions: "impressions";
13
+ position: "position";
14
+ }>>;
15
+ }, z.core.$strip>;
16
+ export declare function validateSite(site: string): string;
17
+ export declare function canonicalSite(site: string): string;
18
+ export declare function validateUrl(site: string, url: string): string;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { WebmasterError } from './errors.js';
3
+ export const date = z.iso.date();
4
+ export const range = z.object({ site: z.string().min(1), from: date, to: date, account: z.string().optional(), limit: z.number().int().min(1).max(25000).optional(), sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional() }).refine(x => x.from <= x.to, 'from must be before to');
5
+ export function validateSite(site) { if (/^sc-domain:[a-z0-9.-]+$/i.test(site))
6
+ return site; try {
7
+ const u = new URL(site);
8
+ if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.username || u.password || u.hash)
9
+ throw new Error();
10
+ return site;
11
+ }
12
+ catch {
13
+ throw new WebmasterError('INVALID_INPUT', 'Site must be an HTTP(S) property URL or sc-domain property.');
14
+ } }
15
+ export function canonicalSite(site) { validateSite(site); if (site.startsWith('sc-domain:'))
16
+ return site.toLowerCase(); const u = new URL(site); u.hash = ''; return u.href; }
17
+ export function validateUrl(site, url) { validateSite(site); try {
18
+ const target = new URL(url);
19
+ if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.hash)
20
+ throw new Error();
21
+ if (site.startsWith('sc-domain:')) {
22
+ const domain = site.slice(10);
23
+ if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`))
24
+ throw new Error();
25
+ }
26
+ else {
27
+ const property = new URL(site);
28
+ if (target.origin !== property.origin || !target.pathname.startsWith(property.pathname))
29
+ throw new Error();
30
+ }
31
+ return target.href;
32
+ }
33
+ catch {
34
+ throw new WebmasterError('INVALID_INPUT', 'URL must be HTTP(S) and belong to the selected site.');
35
+ } }
@@ -0,0 +1,8 @@
1
+ export * from './core/types/index.js';
2
+ export * from './core/analysis/index.js';
3
+ export * from './core/service.js';
4
+ export * from './core/cache/index.js';
5
+ export * from './core/auth/store.js';
6
+ export { GoogleProvider } from './providers/google/index.js';
7
+ export { BingProvider } from './providers/bing/index.js';
8
+ export { YandexProvider } from './providers/yandex/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from './core/types/index.js';
2
+ export * from './core/analysis/index.js';
3
+ export * from './core/service.js';
4
+ export * from './core/cache/index.js';
5
+ export * from './core/auth/store.js';
6
+ export { GoogleProvider } from './providers/google/index.js';
7
+ export { BingProvider } from './providers/bing/index.js';
8
+ export { YandexProvider } from './providers/yandex/index.js';
@@ -0,0 +1 @@
1
+ export declare function createServer(): import("mcponce").McpApp<unknown>;
@@ -0,0 +1,4 @@
1
+ import { createMcpServer } from 'mcponce';
2
+ import { createContext } from '../server/context.js';
3
+ import { registerTools } from './tools/index.js';
4
+ export function createServer() { const { service } = createContext(); const app = createMcpServer({ name: 'webmaster-mcp', version: '0.1.0', background: process.env.MCP_BACKGROUND !== '0', coerceInputs: true, dataDir: process.env.WEBMASTER_MCP_DATA_DIR }); registerTools(app, service); return app; }
@@ -0,0 +1,2 @@
1
+ import type { WebmasterService } from '../../core/service.js';
2
+ export declare function registerTools(app: any, service: WebmasterService): void;
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { compareEngines, findCtrOpportunities, findNearFirstPage, findDecliningPages, findDecliningQueries, findImpressionGrowthWithoutClicks, findEngineGaps, findIndexingGaps } from '../../core/analysis/index.js';
3
+ const engines = z.array(z.enum(['google', 'bing', 'yandex'])).optional();
4
+ const base = { site: z.string(), from: z.iso.date(), to: z.iso.date(), engines, account: z.string().optional() };
5
+ function previousRange(from, to) { const days = (Date.parse(to) - Date.parse(from)) / 86400000 + 1; const end = new Date(Date.parse(from) - 86400000); const start = new Date(end.getTime() - (days - 1) * 86400000); return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) }; }
6
+ export function registerTools(app, service) {
7
+ app.tool({ name: 'sites', description: 'List normalized sites across connected webmaster accounts.', handler: () => service.sites() });
8
+ for (const operation of ['performance', 'queries', 'pages'])
9
+ app.tool({ name: operation, description: `Get normalized ${operation} metrics with partial provider errors.`, inputSchema: z.object({ ...base, limit: z.number().int().min(1).max(25000).optional(), sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional() }), handler: (a) => service.metrics(operation, a, a.engines) });
10
+ app.tool({ name: 'sitemaps', description: 'List submitted sitemaps for a verified site.', inputSchema: z.object({ site: z.string(), engines, account: z.string().optional() }), handler: (a) => service.sitemaps(a.site, a.engines, a.account) });
11
+ app.tool({ name: 'inspect_url', description: 'Inspect URL indexing where provider API offers evidence; unknown means unverified.', inputSchema: z.object({ site: z.string(), url: z.url(), engines, account: z.string().optional() }), handler: (a) => service.inspect(a.site, a.url, a.engines, a.account) });
12
+ app.tool({ name: 'compare_engines', description: 'Compare provider-reported metrics without equating their counting semantics.', inputSchema: z.object(base), handler: async (a) => { const data = await service.metrics('performance', a, a.engines); return { ...data, results: compareEngines(data.results) }; } });
13
+ app.tool({ name: 'opportunities', description: 'Deterministic CTR, ranking and trend candidates with supporting metrics.', inputSchema: z.object({ ...base, minImpressions: z.number().min(0).default(100), maxCtr: z.number().min(0).max(1).default(0.02), declineFraction: z.number().min(0).max(1).default(0.2) }), handler: async (a) => { const prior = previousRange(a.from, a.to); const [queries, pages, oldQueries, oldPages] = await Promise.all([service.metrics('queries', a, a.engines), service.metrics('pages', a, a.engines), service.metrics('queries', { ...a, ...prior }, a.engines), service.metrics('pages', { ...a, ...prior }, a.engines)]); return { results: [...findCtrOpportunities(queries.results, { minImpressions: a.minImpressions, maxCtr: a.maxCtr }), ...findNearFirstPage(queries.results), ...findDecliningPages(pages.results, oldPages.results, a.declineFraction), ...findDecliningQueries(queries.results, oldQueries.results, a.declineFraction), ...findImpressionGrowthWithoutClicks(queries.results, oldQueries.results)], errors: [...queries.errors, ...pages.errors, ...oldQueries.errors, ...oldPages.errors] }; } });
14
+ app.tool({ name: 'indexing_gaps', description: 'Find verified indexing differences for supplied URLs and search visibility gaps. Missing performance rows are not indexing proof.', inputSchema: z.object({ ...base, urls: z.array(z.url()).max(20).optional() }), handler: async (a) => { const pageData = await service.metrics('pages', a, a.engines); const selected = (a.engines ?? ['google', 'bing', 'yandex']).filter(engine => !pageData.errors.some(e => e.engine === engine)); const inspected = await Promise.all((a.urls ?? []).map((url) => service.inspect(a.site, url, a.engines, a.account))); return { results: { visibilityGaps: findEngineGaps(pageData.results, selected), verifiedIndexingGaps: findIndexingGaps(inspected.flatMap(x => x.results)), inspections: inspected.flatMap(x => x.results) }, errors: [...pageData.errors, ...inspected.flatMap(x => x.errors)] }; } });
15
+ }
@@ -0,0 +1,20 @@
1
+ import type { CredentialStore } from '../../core/auth/store.js';
2
+ import type { Capability, PerformanceQuery, SearchMetric, Site, Sitemap, WebmasterProvider } from '../../core/types/index.js';
3
+ export declare class BingProvider implements WebmasterProvider {
4
+ private store;
5
+ readonly account: string;
6
+ private fetcher;
7
+ readonly id: 'bing';
8
+ private scheduler;
9
+ constructor(store: CredentialStore, account?: string, fetcher?: typeof fetch);
10
+ supports(c: Capability): boolean;
11
+ authenticate(): Promise<void>;
12
+ isAuthenticated(): Promise<boolean>;
13
+ private request;
14
+ getSites(): Promise<Site[]>;
15
+ private stats;
16
+ getPerformance(input: PerformanceQuery): Promise<SearchMetric[]>;
17
+ getPages(input: PerformanceQuery): Promise<SearchMetric[]>;
18
+ getQueries(input: PerformanceQuery): Promise<SearchMetric[]>;
19
+ getSitemaps(site: string): Promise<Sitemap[]>;
20
+ }
@@ -0,0 +1,31 @@
1
+ import { WebmasterError } from '../../core/errors.js';
2
+ import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
3
+ import { metric, finite } from '../../core/normalize/index.js';
4
+ import { validateSite } from '../../core/validation.js';
5
+ import { secretPrompt } from '../../core/auth/prompt.js';
6
+ export class BingProvider {
7
+ store;
8
+ account;
9
+ fetcher;
10
+ id = 'bing';
11
+ scheduler = providerScheduler('bing');
12
+ constructor(store, account = 'default', fetcher = fetch) {
13
+ this.store = store;
14
+ this.account = account;
15
+ this.fetcher = fetcher;
16
+ }
17
+ supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps'].includes(c); }
18
+ async authenticate() { const key = await secretPrompt('Bing Webmaster API key: '); if (!key)
19
+ throw new WebmasterError('INVALID_INPUT', 'Empty Bing API key.'); await this.store.set('bing', this.account, { apiKey: key }); }
20
+ async isAuthenticated() { return !!(await this.store.get('bing', this.account)); }
21
+ async request(method, params = {}) { const credential = await this.store.get('bing', this.account); if (!credential?.apiKey)
22
+ throw new WebmasterError('AUTH_FAILED', 'Bing is not connected. Run auth bing.'); const url = new URL(`https://ssl.bing.com/webmaster/api.svc/json/${method}`); url.searchParams.set('apikey', credential.apiKey); for (const [k, v] of Object.entries(params))
23
+ url.searchParams.set(k, v); return this.scheduler.run(async () => { const data = await jsonFetch(this.fetcher, url.href); if (data?.ErrorCode)
24
+ throw new WebmasterError(data.ErrorCode === 3 ? 'AUTH_FAILED' : 'UPSTREAM_ERROR', 'Bing rejected the request.'); return data.d; }); }
25
+ async getSites() { const rows = await this.request('GetUserSites'); return (rows ?? []).filter((x) => x.IsVerified).map((x) => ({ site: x.Url, engine: this.id, account: this.account })); }
26
+ async stats(input, method, dimension) { validateSite(input.site); const rows = await this.request(method, { siteUrl: input.site }); return (rows ?? []).map((r) => { const match = String(r.Date ?? '').match(/\/Date\((\d+)/); const date = match ? new Date(Number(match[1])).toISOString().slice(0, 10) : String(r.Date ?? '').slice(0, 10); return metric({ engine: this.id, site: input.site, account: this.account, date, ...(dimension ? { [dimension]: r.Query } : {}), clicks: finite(r.Clicks), impressions: finite(r.Impressions), position: finite(r.AvgImpressionPosition) }); }).filter((r) => r.date && r.date >= input.from && r.date <= input.to).slice(0, input.limit ?? 25000); }
27
+ getPerformance(input) { return this.stats(input, 'GetRankAndTrafficStats'); }
28
+ getPages(input) { return this.stats(input, 'GetPageStats', 'page'); }
29
+ getQueries(input) { return this.stats(input, 'GetQueryStats', 'query'); }
30
+ async getSitemaps(site) { validateSite(site); const rows = await this.request('GetFeeds', { siteUrl: site }); return (rows ?? []).map((r) => ({ engine: this.id, site, url: r.Url ?? r.FeedUrl, status: r.Status })); }
31
+ }
@@ -0,0 +1,10 @@
1
+ import type { CredentialStore } from '../../core/auth/store.js';
2
+ export interface GoogleCredentials {
3
+ accessToken: string;
4
+ refreshToken?: string;
5
+ expiresAt?: number;
6
+ scope?: string;
7
+ }
8
+ export declare function openBrowser(url: string): void;
9
+ export declare function authenticateGoogle(store: CredentialStore, account: string, write?: boolean, fetcher?: typeof fetch): Promise<void>;
10
+ export declare function googleToken(store: CredentialStore, account: string, fetcher?: typeof fetch): Promise<string>;
@@ -0,0 +1,57 @@
1
+ import { randomBytes, createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { Hono } from 'hono';
4
+ import { serve } from '@hono/node-server';
5
+ import { WebmasterError } from '../../core/errors.js';
6
+ import { jsonFetch } from '../../core/scheduler.js';
7
+ export function openBrowser(url) { const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open'; const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]; const child = spawn(command, args, { stdio: 'ignore', detached: true }); child.on('error', () => { }); child.unref(); }
8
+ export async function authenticateGoogle(store, account, write = false, fetcher = fetch) {
9
+ const clientId = process.env.GOOGLE_CLIENT_ID, clientSecret = process.env.GOOGLE_CLIENT_SECRET;
10
+ if (!clientId)
11
+ throw new WebmasterError('INVALID_INPUT', 'Set GOOGLE_CLIENT_ID to a Desktop OAuth client ID.');
12
+ const state = randomBytes(24).toString('base64url'), verifier = randomBytes(48).toString('base64url');
13
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
14
+ let resolveCode, rejectCode;
15
+ const codePromise = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
16
+ const app = new Hono();
17
+ app.get('/callback', c => { if (c.req.query('state') !== state) {
18
+ return c.text('Invalid OAuth state.', 400);
19
+ } const code = c.req.query('code'); if (!code) {
20
+ rejectCode(new WebmasterError('AUTH_FAILED', 'OAuth authorization was declined.'));
21
+ return c.text('Authorization failed.', 400);
22
+ } resolveCode(code); return c.text('Authorization complete. You can close this tab.'); });
23
+ const server = await new Promise(resolve => { const s = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }, () => resolve(s)); });
24
+ let timer;
25
+ try {
26
+ const address = server.address();
27
+ if (!address || typeof address === 'string')
28
+ throw new Error('No callback port');
29
+ const redirect = `http://127.0.0.1:${address.port}/callback`;
30
+ const scope = write ? 'https://www.googleapis.com/auth/webmasters' : 'https://www.googleapis.com/auth/webmasters.readonly';
31
+ const authorization = new URL('https://accounts.google.com/o/oauth2/v2/auth');
32
+ authorization.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirect, response_type: 'code', scope, access_type: 'offline', prompt: 'consent', state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
33
+ process.stderr.write(`Open this URL if your browser does not start:\n${authorization.href}\n`);
34
+ openBrowser(authorization.href);
35
+ const code = await Promise.race([codePromise, new Promise((_, reject) => { timer = setTimeout(() => reject(new WebmasterError('AUTH_FAILED', 'OAuth callback timed out after two minutes.')), 120000); })]);
36
+ const body = new URLSearchParams({ client_id: clientId, code, code_verifier: verifier, redirect_uri: redirect, grant_type: 'authorization_code' });
37
+ if (clientSecret)
38
+ body.set('client_secret', clientSecret);
39
+ const data = await jsonFetch(fetcher, 'https://oauth2.googleapis.com/token', { method: 'POST', body });
40
+ if (!data.access_token)
41
+ throw new WebmasterError('AUTH_FAILED', 'Google did not issue an access token.');
42
+ const credentials = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000, scope };
43
+ await store.set('google', account, credentials);
44
+ }
45
+ finally {
46
+ if (timer)
47
+ clearTimeout(timer);
48
+ await new Promise(resolve => server.close(() => resolve()));
49
+ }
50
+ }
51
+ export async function googleToken(store, account, fetcher = fetch) { const c = await store.get('google', account); if (!c?.accessToken)
52
+ throw new WebmasterError('AUTH_FAILED', 'Google is not connected. Run auth google.'); if (!c.expiresAt || c.expiresAt > Date.now() + 60000)
53
+ return c.accessToken; if (!c.refreshToken)
54
+ throw new WebmasterError('AUTH_FAILED', 'Google token expired. Run auth google again.'); const clientId = process.env.GOOGLE_CLIENT_ID; if (!clientId)
55
+ throw new WebmasterError('AUTH_FAILED', 'GOOGLE_CLIENT_ID is required to refresh the token.'); const body = new URLSearchParams({ client_id: clientId, refresh_token: c.refreshToken, grant_type: 'refresh_token' }); if (process.env.GOOGLE_CLIENT_SECRET)
56
+ body.set('client_secret', process.env.GOOGLE_CLIENT_SECRET); const data = await jsonFetch(fetcher, 'https://oauth2.googleapis.com/token', { method: 'POST', body }); if (!data.access_token)
57
+ throw new WebmasterError('AUTH_FAILED', 'Google token refresh failed.'); const updated = { ...c, accessToken: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000 }; await store.set('google', account, updated); return updated.accessToken; }
@@ -0,0 +1,22 @@
1
+ import type { CredentialStore } from '../../core/auth/store.js';
2
+ import type { Capability, PerformanceQuery, SearchMetric, Site, Sitemap, UrlInspection, WebmasterProvider } from '../../core/types/index.js';
3
+ export declare class GoogleProvider implements WebmasterProvider {
4
+ private store;
5
+ readonly account: string;
6
+ private fetcher;
7
+ private write;
8
+ readonly id: 'google';
9
+ private scheduler;
10
+ constructor(store: CredentialStore, account?: string, fetcher?: typeof fetch, write?: boolean);
11
+ supports(c: Capability): boolean;
12
+ authenticate(): Promise<void>;
13
+ isAuthenticated(): Promise<boolean>;
14
+ private request;
15
+ getSites(): Promise<Site[]>;
16
+ private search;
17
+ getPerformance(input: PerformanceQuery): Promise<SearchMetric[]>;
18
+ getPages(input: PerformanceQuery): Promise<SearchMetric[]>;
19
+ getQueries(input: PerformanceQuery): Promise<SearchMetric[]>;
20
+ getSitemaps(site: string): Promise<Sitemap[]>;
21
+ inspectUrl(site: string, url: string): Promise<UrlInspection>;
22
+ }
@@ -0,0 +1,35 @@
1
+ import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
2
+ import { metric } from '../../core/normalize/index.js';
3
+ import { validateSite, validateUrl } from '../../core/validation.js';
4
+ import { authenticateGoogle, googleToken } from './auth.js';
5
+ export class GoogleProvider {
6
+ store;
7
+ account;
8
+ fetcher;
9
+ write;
10
+ id = 'google';
11
+ scheduler = providerScheduler('google');
12
+ constructor(store, account = 'default', fetcher = fetch, write = false) {
13
+ this.store = store;
14
+ this.account = account;
15
+ this.fetcher = fetcher;
16
+ this.write = write;
17
+ }
18
+ supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c); }
19
+ authenticate() { return authenticateGoogle(this.store, this.account, this.write, this.fetcher); }
20
+ async isAuthenticated() { return !!(await this.store.get('google', this.account)); }
21
+ async request(url, body) { return this.scheduler.run(async () => jsonFetch(this.fetcher, url, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${await googleToken(this.store, this.account, this.fetcher)}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })); }
22
+ async getSites() { const data = await this.request('https://www.googleapis.com/webmasters/v3/sites'); return (data.siteEntry ?? []).map((s) => ({ site: s.siteUrl, engine: this.id, account: this.account, permission: s.permissionLevel })); }
23
+ async search(input, dimensions) { validateSite(input.site); const site = encodeURIComponent(input.site); const result = []; const limit = Math.min(input.limit ?? 25000, 25000); for (let startRow = 0; startRow < limit; startRow += 25000) {
24
+ const data = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`, { startDate: input.from, endDate: input.to, dimensions, rowLimit: Math.min(25000, limit - startRow), startRow });
25
+ const rows = data.rows ?? [];
26
+ result.push(...rows.map((r) => metric({ engine: this.id, site: input.site, account: this.account, ...Object.fromEntries(dimensions.map((d, i) => [d, r.keys?.[i]])), clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position })));
27
+ if (rows.length < Math.min(25000, limit - startRow))
28
+ break;
29
+ } return result; }
30
+ getPerformance(input) { return this.search(input, ['date']); }
31
+ getPages(input) { return this.search(input, ['page']); }
32
+ getQueries(input) { return this.search(input, ['query']); }
33
+ async getSitemaps(site) { validateSite(site); const d = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(site)}/sitemaps`); return (d.sitemap ?? []).map((s) => ({ engine: this.id, site, url: s.path, submitted: s.lastSubmitted, status: s.isPending ? 'pending' : undefined })); }
34
+ async inspectUrl(site, url) { validateUrl(site, url); const d = await this.request('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { siteUrl: site, inspectionUrl: url }); const status = d.inspectionResult?.indexStatusResult; return { engine: this.id, site, url, state: status?.verdict === 'PASS' ? 'indexed' : status?.verdict === 'FAIL' || status?.verdict === 'NEUTRAL' ? 'not_indexed' : 'unknown', reason: status?.coverageState, inspectedAt: status?.lastCrawlTime }; }
35
+ }
@@ -0,0 +1,5 @@
1
+ import type { CredentialStore } from '../../core/auth/store.js';
2
+ export declare function yandexAuthorizationUrl(): string;
3
+ export declare function beginYandexAuth(): void;
4
+ export declare function saveYandexToken(store: CredentialStore, account: string, token: string): Promise<void>;
5
+ export declare function authenticateYandex(store: CredentialStore, account: string): Promise<void>;
@@ -0,0 +1,9 @@
1
+ import { WebmasterError } from '../../core/errors.js';
2
+ import { openBrowser } from '../google/auth.js';
3
+ import { secretPrompt } from '../../core/auth/prompt.js';
4
+ export function yandexAuthorizationUrl() { const clientId = process.env.YANDEX_CLIENT_ID; if (!clientId)
5
+ throw new WebmasterError('INVALID_INPUT', 'Set YANDEX_CLIENT_ID for a Yandex OAuth app with webmaster:hostinfo permission.'); const url = new URL('https://oauth.yandex.com/authorize'); url.searchParams.set('response_type', 'token'); url.searchParams.set('client_id', clientId); return url.href; }
6
+ export function beginYandexAuth() { const url = yandexAuthorizationUrl(); process.stderr.write(`Open Yandex OAuth and copy the displayed token:\n${url}\n`); openBrowser(url); }
7
+ export async function saveYandexToken(store, account, token) { if (!/^[A-Za-z0-9._~-]{10,}$/.test(token))
8
+ throw new WebmasterError('INVALID_INPUT', 'Invalid Yandex OAuth token format.'); await store.set('yandex', account, { accessToken: token }); }
9
+ export async function authenticateYandex(store, account) { beginYandexAuth(); await saveYandexToken(store, account, await secretPrompt('Yandex OAuth token: ')); }
@@ -0,0 +1,22 @@
1
+ import type { CredentialStore } from '../../core/auth/store.js';
2
+ import type { Capability, PerformanceQuery, SearchMetric, Site, Sitemap, UrlInspection, WebmasterProvider } from '../../core/types/index.js';
3
+ export declare class YandexProvider implements WebmasterProvider {
4
+ private store;
5
+ readonly account: string;
6
+ private fetcher;
7
+ readonly id: 'yandex';
8
+ private scheduler;
9
+ constructor(store: CredentialStore, account?: string, fetcher?: typeof fetch);
10
+ supports(c: Capability): boolean;
11
+ authenticate(): Promise<void>;
12
+ isAuthenticated(): Promise<boolean>;
13
+ private request;
14
+ private userId;
15
+ private host;
16
+ getSites(): Promise<Site[]>;
17
+ getPerformance(input: PerformanceQuery): Promise<SearchMetric[]>;
18
+ getQueries(input: PerformanceQuery): Promise<SearchMetric[]>;
19
+ getPages(input: PerformanceQuery): Promise<SearchMetric[]>;
20
+ getSitemaps(site: string): Promise<Sitemap[]>;
21
+ inspectUrl(site: string, url: string): Promise<UrlInspection>;
22
+ }
@@ -0,0 +1,70 @@
1
+ import { WebmasterError } from '../../core/errors.js';
2
+ import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
3
+ import { metric, finite } from '../../core/normalize/index.js';
4
+ import { validateSite, validateUrl } from '../../core/validation.js';
5
+ import { authenticateYandex } from './auth.js';
6
+ export class YandexProvider {
7
+ store;
8
+ account;
9
+ fetcher;
10
+ id = 'yandex';
11
+ scheduler = providerScheduler('yandex');
12
+ constructor(store, account = 'default', fetcher = fetch) {
13
+ this.store = store;
14
+ this.account = account;
15
+ this.fetcher = fetcher;
16
+ }
17
+ supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c); }
18
+ authenticate() { return authenticateYandex(this.store, this.account); }
19
+ async isAuthenticated() { return !!(await this.store.get('yandex', this.account)); }
20
+ async request(path, body) { const credential = await this.store.get('yandex', this.account); if (!credential?.accessToken)
21
+ throw new WebmasterError('AUTH_FAILED', 'Yandex is not connected. Run auth yandex.'); return this.scheduler.run(() => jsonFetch(this.fetcher, `https://api.webmaster.yandex.net/v4/${path}`, { method: body ? 'POST' : 'GET', headers: { Authorization: `OAuth ${credential.accessToken}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })); }
22
+ async userId() { const d = await this.request('user'); return String(d.user_id); }
23
+ async host(site) { validateSite(site); const user = await this.userId(); const d = await this.request(`user/${encodeURIComponent(user)}/hosts`); const found = (d.hosts ?? []).find((h) => h.ascii_host_url === site || h.unicode_host_url === site); if (!found)
24
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not in this Yandex account.'); return { user, host: found.host_id }; }
25
+ async getSites() { const user = await this.userId(); const d = await this.request(`user/${encodeURIComponent(user)}/hosts`); return (d.hosts ?? []).filter((h) => h.verified).map((h) => ({ site: h.ascii_host_url, engine: this.id, account: this.account })); }
26
+ async getPerformance(input) { const { user, host } = await this.host(input.site); const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/all/history`); for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
27
+ url.searchParams.append('query_indicator', x); url.searchParams.set('date_from', input.from); url.searchParams.set('date_to', input.to); const d = await this.request(url.pathname.slice(4) + url.search); const byDate = new Map(); for (const [key, items] of Object.entries(d.indicators ?? {}))
28
+ for (const item of items) {
29
+ const date = String(item.date).slice(0, 10);
30
+ const row = byDate.get(date) ?? { engine: this.id, site: input.site, account: this.account, date };
31
+ if (key === 'TOTAL_SHOWS')
32
+ row.impressions = finite(item.value);
33
+ if (key === 'TOTAL_CLICKS')
34
+ row.clicks = finite(item.value);
35
+ if (key === 'AVG_SHOW_POSITION')
36
+ row.position = finite(item.value);
37
+ byDate.set(date, row);
38
+ } return [...byDate.values()].map(metric); }
39
+ async getQueries(input) { const { user, host } = await this.host(input.site); const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/popular`); url.searchParams.set('order_by', 'TOTAL_CLICKS'); for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
40
+ url.searchParams.append('query_indicator', x); url.searchParams.set('date_from', input.from); url.searchParams.set('date_to', input.to); const max = Math.min(input.limit ?? 500, 3000); const rows = []; for (let offset = 0; offset < max; offset += 500) {
41
+ url.searchParams.set('offset', String(offset));
42
+ url.searchParams.set('limit', String(Math.min(500, max - offset)));
43
+ const d = await this.request(url.pathname.slice(4) + url.search);
44
+ const batch = d.queries ?? [];
45
+ rows.push(...batch.map((q) => metric({ engine: this.id, site: input.site, account: this.account, query: q.query_text, clicks: q.indicators?.TOTAL_CLICKS, impressions: q.indicators?.TOTAL_SHOWS, position: q.indicators?.AVG_SHOW_POSITION })));
46
+ if (batch.length < Math.min(500, max - offset))
47
+ break;
48
+ } return rows; }
49
+ async getPages(input) { const earliest = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10); if (input.from < earliest)
50
+ throw new WebmasterError('UNSUPPORTED', 'Yandex page analytics is available only for the last two weeks.'); const { user, host } = await this.host(input.site); const rows = []; const max = input.limit ?? 500; for (let offset = 0; offset < max; offset += 500) {
51
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/query-analytics/list`, { offset, limit: Math.min(500, max - offset), text_indicator: 'URL', search_location: 'WEB_LOCATION' });
52
+ const items = d.text_indicator_to_statistics ?? [];
53
+ for (const item of items) {
54
+ const dates = new Map();
55
+ for (const stat of item.statistics ?? []) {
56
+ if (stat.date < input.from || stat.date > input.to)
57
+ continue;
58
+ const row = dates.get(stat.date) ?? {};
59
+ row[stat.field] = stat.value;
60
+ dates.set(stat.date, row);
61
+ }
62
+ for (const [date, s] of dates)
63
+ rows.push(metric({ engine: this.id, site: input.site, account: this.account, page: item.text_indicator?.value, date, clicks: s.CLICKS, impressions: s.IMPRESSIONS, position: s.POSITION }));
64
+ }
65
+ if (items.length < Math.min(500, max - offset))
66
+ break;
67
+ } return rows; }
68
+ async getSitemaps(site) { const { user, host } = await this.host(site); const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/sitemaps`); return (d.sitemaps ?? []).map((s) => ({ engine: this.id, site, url: s.sitemap_url ?? s.url, status: s.status })); }
69
+ async inspectUrl(site, url) { validateUrl(site, url); const { user, host } = await this.host(site); const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/important-urls`); const item = (d.urls ?? []).find((x) => x.url === url); const searchable = item?.search_status?.searchable; return { engine: this.id, site, url, state: searchable === true ? 'indexed' : searchable === false ? 'not_indexed' : 'unknown', reason: item ? item.search_status?.excluded_url_status : 'URL is not in Yandex important-page monitoring.', inspectedAt: item?.update_date }; }
70
+ }
@@ -0,0 +1,10 @@
1
+ import { Cache } from '../core/cache/index.js';
2
+ import { KeychainCredentialStore } from '../core/auth/store.js';
3
+ import { AccountRegistry } from '../core/auth/accounts.js';
4
+ import { WebmasterService } from '../core/service.js';
5
+ export declare function createContext(): {
6
+ cache: Cache;
7
+ store: KeychainCredentialStore;
8
+ accounts: AccountRegistry;
9
+ service: WebmasterService;
10
+ };
@@ -0,0 +1,10 @@
1
+ import { Cache } from '../core/cache/index.js';
2
+ import { KeychainCredentialStore } from '../core/auth/store.js';
3
+ import { AccountRegistry } from '../core/auth/accounts.js';
4
+ import { GoogleProvider } from '../providers/google/index.js';
5
+ import { BingProvider } from '../providers/bing/index.js';
6
+ import { YandexProvider } from '../providers/yandex/index.js';
7
+ import { WebmasterService } from '../core/service.js';
8
+ export function createContext() { const cache = new Cache(); const store = new KeychainCredentialStore(); const accounts = new AccountRegistry(cache, store); const buildProviders = () => { const providers = []; for (const engine of ['google', 'bing', 'yandex'])
9
+ for (const name of accounts.names(engine))
10
+ providers.push(engine === 'google' ? new GoogleProvider(store, name) : engine === 'bing' ? new BingProvider(store, name) : new YandexProvider(store, name)); return providers; }; return { cache, store, accounts, service: new WebmasterService(buildProviders(), cache, buildProviders) }; }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "webmaster-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local-first unified webmaster MCP for Google, Bing, and Yandex",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "webmaster-mcp": "dist/cli/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "test": "node --import tsx --test test/*.test.ts",
17
+ "postbuild": "node -e \"require('node:fs').chmodSync('dist/cli/index.js', 0o755)\"",
18
+ "prepack": "npm run build"
19
+ },
20
+ "keywords": [],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "dependencies": {
28
+ "@hono/node-server": "^2.1.1",
29
+ "@napi-rs/keyring": "^2.1.0",
30
+ "better-sqlite3": "^11.10.0",
31
+ "hono": "^4.13.8",
32
+ "mcponce": "^0.2.3",
33
+ "unitup": "^0.3.0",
34
+ "zod": "^4.6.5"
35
+ },
36
+ "devDependencies": {
37
+ "@types/better-sqlite3": "^9.6.0",
38
+ "@types/node": "^26.6.2",
39
+ "tsx": "^4.23.15",
40
+ "typescript": "^7.0.2"
41
+ }
42
+ }