@georanker/seo-mcp 0.14.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.
@@ -0,0 +1,278 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
+ import { AppError } from './errors.js';
6
+ import { secureUrl } from './config.js';
7
+ import { installationHeaders } from './enrollment.js';
8
+ import { deviceFingerprint } from './identity.js';
9
+ export { deviceFingerprint } from './identity.js';
10
+ import { PRODUCT_PROFILES, SERVER_VERSION } from './product-contract.js';
11
+ import { SEO_REPORT_CATALOG, SEO_REPORT_HEADER, SEO_TOOL_NAMES } from './seo-contract.js';
12
+ import { createServer } from './server.js';
13
+ export const PILOT_MCP_URL = 'https://grmcp.ibl.ro/mcp';
14
+ const MAX_ERROR_TEXT = 8192;
15
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
16
+ const localCatalogs = new Map();
17
+ function localInputSchemas(service, profile) {
18
+ let catalog = localCatalogs.get(profile);
19
+ if (!catalog) {
20
+ // Use the same SDK serialization as the advertised local tools. This
21
+ // metadata-only exchange cannot invoke a provider or create a data job.
22
+ catalog = (async () => {
23
+ const server = createServer(service, profile);
24
+ const client = new Client({ name: 'local-schema-check', version: SERVER_VERSION });
25
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
26
+ try {
27
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
28
+ const listed = await client.listTools();
29
+ return new Map(listed.tools.map(tool => [tool.name, tool.inputSchema]));
30
+ }
31
+ finally {
32
+ await client.close();
33
+ await server.close();
34
+ }
35
+ })().catch(error => { localCatalogs.delete(profile); throw error; });
36
+ localCatalogs.set(profile, catalog);
37
+ }
38
+ return catalog;
39
+ }
40
+ function canonicalSchema(value, mode = 'schema') {
41
+ if (Array.isArray(value))
42
+ return value.map(item => canonicalSchema(item, mode));
43
+ const data = record(value);
44
+ if (!data)
45
+ return value;
46
+ return Object.fromEntries(Object.keys(data).sort().flatMap(key => {
47
+ if (mode === 'schema' && ['description', 'title', '$schema', '$comment', 'examples'].includes(key))
48
+ return [];
49
+ let childMode = mode === 'map' ? 'schema' : mode;
50
+ if (mode === 'schema') {
51
+ if (['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas', 'dependencies'].includes(key))
52
+ childMode = 'map';
53
+ else if (['default', 'const', 'enum', 'required'].includes(key))
54
+ childMode = 'data';
55
+ }
56
+ let child = canonicalSchema(data[key], childMode);
57
+ if (mode === 'schema' && ['enum', 'required'].includes(key) && Array.isArray(child))
58
+ child = child.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
59
+ return [[key, child]];
60
+ }));
61
+ }
62
+ function schemaDifferences(expected, actual, path = 'inputSchema') {
63
+ if (JSON.stringify(expected) === JSON.stringify(actual))
64
+ return [];
65
+ const left = record(expected), right = record(actual);
66
+ if (!left || !right)
67
+ return [path];
68
+ return [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()
69
+ .flatMap(key => schemaDifferences(left[key], right[key], `${path}.${key}`)).slice(0, 12);
70
+ }
71
+ function recoveryFields(value) {
72
+ const data = record(value), fields = {};
73
+ if (typeof data?.reportId === 'string' && /^seo_[a-f0-9-]{36}$/.test(data.reportId))
74
+ fields.reportId = data.reportId;
75
+ if (typeof data?.jobId === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(data.jobId))
76
+ fields.jobId = data.jobId;
77
+ if (typeof data?.submissionUncertain === 'boolean')
78
+ fields.submissionUncertain = data.submissionUncertain;
79
+ return fields;
80
+ }
81
+ function textDiagnostic(content) {
82
+ let text = '', truncated = false;
83
+ if (Array.isArray(content))
84
+ for (const item of content) {
85
+ const block = record(item);
86
+ if (block?.type !== 'text' || typeof block.text !== 'string' || !block.text.trim())
87
+ continue;
88
+ const separator = text ? '\n' : '';
89
+ const remaining = MAX_ERROR_TEXT - text.length - separator.length;
90
+ if (remaining <= 0) {
91
+ truncated = true;
92
+ break;
93
+ }
94
+ text += separator + block.text.slice(0, remaining);
95
+ if (block.text.length > remaining) {
96
+ truncated = true;
97
+ break;
98
+ }
99
+ }
100
+ let value;
101
+ if (text && !truncated) {
102
+ try {
103
+ value = record(JSON.parse(text));
104
+ }
105
+ catch { /* SDK validation errors are ordinary text. */ }
106
+ }
107
+ // Recover only explicitly labelled identifiers. Prose cannot prove that a
108
+ // submission was refused, so never infer submission certainty from text.
109
+ const reportId = text.match(/\breportId["']?\s*[:=]\s*["']?(seo_[a-f0-9-]{36})(?![a-zA-Z0-9_-])/i)?.[1];
110
+ const jobId = text.match(/\bjobId["']?\s*[:=]\s*["']?([a-zA-Z0-9_-]{1,200})(?![a-zA-Z0-9_-])/i)?.[1];
111
+ return { text: text.trim(), truncated, value, details: { ...recoveryFields({ reportId, jobId }), ...(truncated ? { diagnosticTruncated: true } : {}) } };
112
+ }
113
+ function remoteToolError(result) {
114
+ const diagnostic = textDiagnostic(result.content);
115
+ const structured = record(result.structuredContent);
116
+ const envelope = structured && (record(structured.error) || typeof structured.message === 'string') ? structured : diagnostic.value;
117
+ const error = record(envelope?.error) ?? envelope;
118
+ const code = typeof error?.code === 'string' && error.code.length > 0 && error.code.length <= 128 ? error.code : 'REMOTE_ERROR';
119
+ const message = typeof error?.message === 'string' && error.message.trim() ? error.message : diagnostic.text || 'The hosted tool failed without a diagnostic.';
120
+ const retry = typeof error?.retryAfterSeconds === 'number' && Number.isFinite(error.retryAfterSeconds) && error.retryAfterSeconds >= 0 ? error.retryAfterSeconds : undefined;
121
+ return new AppError(code, message, retry, { ...diagnostic.details, ...recoveryFields(structured), ...recoveryFields(envelope), ...recoveryFields(error), ...record(error?.details) });
122
+ }
123
+ function requestError(error, name, input, profile) {
124
+ const arguments_ = record(input);
125
+ const details = { ...recoveryFields({ reportId: arguments_?.reportId, jobId: arguments_?.jobId }), ...error.details };
126
+ details.submissionUncertain = typeof details.submissionUncertain === 'boolean' ? details.submissionUncertain : !name.startsWith('get_');
127
+ details.automaticRetryPerformed = false;
128
+ if (typeof details.nextAction !== 'string' || !details.nextAction.trim()) {
129
+ if (details.reportId) {
130
+ const getter = name === 'update_rank_tracking_schedule' ? 'get_rank_tracking_report' : name.replace(/^create_/, 'get_');
131
+ details.nextAction = `Retain this reportId and use ${getter} to check the existing report before submitting another report or schedule change.`;
132
+ }
133
+ else if (details.jobId) {
134
+ details.nextAction = `Retain this jobId and use ${PRODUCT_PROFILES[profile].resultTool} to check the existing job before submitting another job.`;
135
+ }
136
+ else if (details.submissionUncertain) {
137
+ details.nextAction = 'The submission outcome is unknown. Ask the operator to reconcile this request before resubmitting; another create call could duplicate paid work.';
138
+ }
139
+ else {
140
+ details.nextAction = name.startsWith('get_')
141
+ ? 'Retry this retrieval with the same identifier after resolving the error or waiting for retryAfterSeconds. Do not create a replacement job.'
142
+ : 'No new work was submitted. Resolve the reported error or wait for retryAfterSeconds before explicitly retrying this request.';
143
+ }
144
+ }
145
+ return new AppError(error.code, error.message, error.retryAfterSeconds, details);
146
+ }
147
+ function queuedRefusal(error) {
148
+ // The pinned SDK includes the HTTP error body in this error. Accept only our
149
+ // bounded, explicit pre-dispatch refusal; other failures stay uncertain.
150
+ if (!(error instanceof StreamableHTTPError) || error.code !== 503 || error.message.length > 8192)
151
+ return;
152
+ const prefix = 'Streamable HTTP error: Error POSTing to endpoint: ';
153
+ if (!error.message.startsWith(prefix))
154
+ return;
155
+ try {
156
+ const value = JSON.parse(error.message.slice(prefix.length)).error;
157
+ if (value?.code === 'CAPACITY_LIMIT' && value.details?.submissionUncertain === false && ['queue_full', 'queue_timeout'].includes(value.details.reason)) {
158
+ return new AppError('CAPACITY_LIMIT', 'The hosted MCP request queue is busy. No tool was started. Retry shortly.', 1, { submissionUncertain: false, reason: value.details.reason });
159
+ }
160
+ }
161
+ catch { /* An unrecognized response cannot prove whether work started. */ }
162
+ }
163
+ export class RemoteService {
164
+ env;
165
+ profile;
166
+ connection;
167
+ url;
168
+ headers;
169
+ constructor(env, profile = 'combined') {
170
+ this.env = env;
171
+ this.profile = profile;
172
+ this.url = secureUrl(env.GEORANKER_MCP_URL || new URL(PRODUCT_PROFILES[profile].endpoint, PILOT_MCP_URL).href, 'GEORANKER_MCP_URL');
173
+ if (!env.GEORANKER_ACCESS_TOKEN && !env.GEORANKER_INSTALLATION_ID)
174
+ return;
175
+ if (!env.GEORANKER_ACCESS_TOKEN || !env.GEORANKER_INSTALLATION_ID)
176
+ throw new AppError('CONFIG_ERROR', 'Set both manual credential fields or neither for automatic registration.');
177
+ const fingerprint = env.GEORANKER_DEVICE_FINGERPRINT || deviceFingerprint();
178
+ if (!/^[a-f0-9]{64}$/.test(fingerprint))
179
+ throw new AppError('CONFIG_ERROR', 'GEORANKER_DEVICE_FINGERPRINT must be a lowercase SHA-256 hex string.');
180
+ this.headers = { Authorization: `Bearer ${env.GEORANKER_ACCESS_TOKEN}`, 'X-GeoRanker-Installation-Id': env.GEORANKER_INSTALLATION_ID, 'X-GeoRanker-Device-Fingerprint': fingerprint };
181
+ }
182
+ connect() {
183
+ if (!this.connection) {
184
+ const client = new Client({ name: `${PRODUCT_PROFILES[this.profile].name}-bridge`, version: SERVER_VERSION });
185
+ this.connection = (async () => {
186
+ this.headers ??= await installationHeaders(this.url, this.env);
187
+ if (this.profile === 'seo')
188
+ this.headers[SEO_REPORT_HEADER] = SEO_REPORT_CATALOG;
189
+ // JSON-RPC IDs are unique only inside this transport. The namespace
190
+ // lets a stateless server correlate cancellation without affecting
191
+ // another AI host sharing the same installation credentials.
192
+ const headers = { ...this.headers, 'X-GeoRanker-Request-Group': randomUUID() };
193
+ const transport = new StreamableHTTPClientTransport(this.url, { requestInit: { headers, redirect: 'error' } });
194
+ await client.connect(transport);
195
+ return client;
196
+ })().catch(async (error) => {
197
+ this.connection = undefined;
198
+ await client.close().catch(() => { });
199
+ if (error instanceof AppError)
200
+ throw new AppError(error.code, error.message, error.retryAfterSeconds, { ...error.details, submissionUncertain: false });
201
+ const refusal = queuedRefusal(error);
202
+ if (refusal)
203
+ throw refusal;
204
+ throw new AppError('REMOTE_CONNECTION_FAILED', 'Cannot connect to the hosted MCP. Check its URL, installation credentials, device fingerprint, and availability.', undefined, { submissionUncertain: false });
205
+ });
206
+ }
207
+ return this.connection;
208
+ }
209
+ async initialize() {
210
+ const listed = await (await this.connect()).listTools();
211
+ const product = PRODUCT_PROFILES[this.profile];
212
+ const required = [product.resultTool, ...('searchTool' in product ? [product.searchTool] : []), ...('fetchTool' in product ? [product.fetchTool] : []), ...(this.profile === 'seo' ? SEO_TOOL_NAMES : [])].sort();
213
+ const available = listed.tools.map(tool => tool.name).sort();
214
+ if (required.length !== available.length || required.some((name, index) => name !== available[index])) {
215
+ throw new AppError('REMOTE_PROFILE_MISMATCH', `The hosted endpoint does not provide the expected tools for ${product.title}. Check GEORANKER_MCP_URL and the server deployment. No query was submitted.`, undefined, { submissionUncertain: false, automaticRetryPerformed: false });
216
+ }
217
+ const local = await localInputSchemas(this, this.profile);
218
+ const differences = listed.tools.flatMap(tool => {
219
+ const paths = schemaDifferences(canonicalSchema(local.get(tool.name)), canonicalSchema(tool.inputSchema));
220
+ return paths.length ? [{ toolName: tool.name, paths }] : [];
221
+ });
222
+ if (differences.length) {
223
+ throw new AppError('REMOTE_SCHEMA_MISMATCH', `The local and hosted input contracts differ for ${differences.map(item => item.toolName).join(', ')}. Update the client and hosted service to compatible versions before submitting work. No query was submitted.`, undefined, {
224
+ submissionUncertain: false, automaticRetryPerformed: false, schemaDifferences: differences,
225
+ nextAction: 'Compare the listed inputSchema fields and update the mismatched client or hosted deployment. Rerun setup before creating any jobs.',
226
+ });
227
+ }
228
+ }
229
+ async call(name, input, signal) {
230
+ try {
231
+ if (signal?.aborted)
232
+ throw new AppError('CANCELLED', 'The hosted MCP request was cancelled before submission.', undefined, { submissionUncertain: false });
233
+ const client = await this.connect();
234
+ if (signal?.aborted)
235
+ throw new AppError('CANCELLED', 'The hosted MCP request was cancelled before submission.', undefined, { submissionUncertain: false });
236
+ const result = await client.callTool({ name, arguments: { ...input } }, undefined, { signal, timeout: 100_000 });
237
+ if (result.isError)
238
+ throw remoteToolError(result);
239
+ const value = record(result.structuredContent);
240
+ if (!value) {
241
+ const diagnostic = textDiagnostic(result.content);
242
+ throw new AppError('REMOTE_RESPONSE_INVALID', 'The hosted MCP returned an unsupported result. A submitted job may still exist. No automatic tool retry was performed.', undefined, { ...diagnostic.details, ...recoveryFields({ reportId: diagnostic.value?.reportId, jobId: diagnostic.value?.jobId }) });
243
+ }
244
+ return value;
245
+ }
246
+ catch (error) {
247
+ if (error instanceof AppError)
248
+ throw requestError(error, name, input, this.profile);
249
+ const refusal = queuedRefusal(error);
250
+ if (refusal)
251
+ throw requestError(refusal, name, input, this.profile);
252
+ throw requestError(new AppError('REMOTE_REQUEST_FAILED', 'The hosted MCP request did not complete. No automatic tool retry was performed.'), name, input, this.profile);
253
+ }
254
+ }
255
+ search(input, signal) {
256
+ const product = PRODUCT_PROFILES[this.profile];
257
+ if (!('searchTool' in product))
258
+ return Promise.reject(new AppError('PROFILE_TOOL_UNAVAILABLE', 'SERP search is not available through this MCP profile.'));
259
+ return this.call(product.searchTool, input, signal);
260
+ }
261
+ seoReport(name, input, signal) {
262
+ if (this.profile !== 'seo' || !SEO_TOOL_NAMES.includes(name))
263
+ return Promise.reject(new AppError('PROFILE_TOOL_UNAVAILABLE', 'SEO reports require the SEO MCP profile.'));
264
+ return this.call(name, input, signal);
265
+ }
266
+ fetchPage(input, signal) {
267
+ const product = PRODUCT_PROFILES[this.profile];
268
+ if (!('fetchTool' in product))
269
+ return Promise.reject(new AppError('PROFILE_TOOL_UNAVAILABLE', 'Page fetching is not available through this MCP profile.'));
270
+ return this.call(product.fetchTool, input, signal);
271
+ }
272
+ getSearchResult(input, signal) {
273
+ const { expectedKind: _expectedKind, ...arguments_ } = input;
274
+ return this.call(PRODUCT_PROFILES[this.profile].resultTool, arguments_, signal);
275
+ }
276
+ async close() { if (this.connection)
277
+ await (await this.connection).close(); }
278
+ }
@@ -0,0 +1 @@
1
+ export declare function run(): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { AppError } from './errors.js';
3
+ import { RemoteService } from './remote.js';
4
+ import { createServer, SERVER_VERSION } from './server.js';
5
+ import { CLIENT_PROFILE } from './product.js';
6
+ export async function run() {
7
+ const args = process.argv.slice(2);
8
+ if (args.length > 1 || (args.length === 1 && !['--setup', '--help', '-h', '--version', '-v'].includes(args[0]))) {
9
+ throw new AppError('CLI_ARGUMENTS', 'Unsupported argument. Use --help. This package only connects to the hosted GeoRanker service.');
10
+ }
11
+ if (args[0] === '--version' || args[0] === '-v') {
12
+ process.stdout.write(`${SERVER_VERSION}\n`);
13
+ return;
14
+ }
15
+ if (args[0] === '--help' || args[0] === '-h') {
16
+ process.stdout.write(`GeoRanker ${CLIENT_PROFILE} MCP ${SERVER_VERSION}\n\nLaunch with no arguments from an MCP host.\n--setup: verify automatic enrollment and tool discovery without a data request.\n--version: show version.\n--update: prepare the latest signed release for an idle worker swap or next launch.\nUpdates check quietly every five minutes; workers switch after 60 idle seconds; set GEORANKER_MCP_AUTO_UPDATE=0 to opt out.\nOptional GEORANKER_MCP_URL and GEORANKER_STATE_DIR.\nBoth GeoRanker products share the existing installation identity for the same service origin.\n`);
17
+ return;
18
+ }
19
+ const service = new RemoteService({ ...process.env }, CLIENT_PROFILE);
20
+ try {
21
+ await service.initialize();
22
+ if (args[0] === '--setup') {
23
+ process.stdout.write(`GeoRanker ${CLIENT_PROFILE} MCP is connected. No data query was submitted.\n`);
24
+ await service.close();
25
+ return;
26
+ }
27
+ const server = createServer(service, CLIENT_PROFILE);
28
+ server.server.onclose = () => { void service.close().catch(() => { }); };
29
+ await server.connect(new StdioServerTransport());
30
+ }
31
+ catch (error) {
32
+ await service.close().catch(() => { });
33
+ throw error;
34
+ }
35
+ }
@@ -0,0 +1,10 @@
1
+ export declare const RESULTS_PER_PAGE = 10;
2
+ export declare const MAX_SEARCH_PAGES = 10;
3
+ export declare const MAX_SEARCH_RESULTS: number;
4
+ export declare function resolveSearchDepth(input: {
5
+ pages?: number;
6
+ limit?: number;
7
+ }): {
8
+ maxResults: number;
9
+ limit: number;
10
+ };
@@ -0,0 +1,19 @@
1
+ import { AppError } from './errors.js';
2
+ export const RESULTS_PER_PAGE = 10;
3
+ export const MAX_SEARCH_PAGES = 10;
4
+ export const MAX_SEARCH_RESULTS = RESULTS_PER_PAGE * MAX_SEARCH_PAGES;
5
+ // Pages express organic-result depth from the first result, not a page offset.
6
+ export function resolveSearchDepth(input) {
7
+ if (input.pages !== undefined && (!Number.isInteger(input.pages) || input.pages < 1 || input.pages > MAX_SEARCH_PAGES)) {
8
+ throw new AppError('INVALID_INPUT', `pages must be an integer between 1 and ${MAX_SEARCH_PAGES}.`);
9
+ }
10
+ if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > MAX_SEARCH_RESULTS)) {
11
+ throw new AppError('INVALID_INPUT', `limit must be an integer between 1 and ${MAX_SEARCH_RESULTS}.`);
12
+ }
13
+ const pages = input.pages ?? Math.ceil((input.limit ?? RESULTS_PER_PAGE) / RESULTS_PER_PAGE);
14
+ const maxResults = pages * RESULTS_PER_PAGE;
15
+ const limit = input.limit ?? maxResults;
16
+ if (limit > maxResults)
17
+ throw new AppError('INVALID_INPUT', 'limit cannot exceed pages × 10. Increase pages or omit it to infer the depth from limit.');
18
+ return { maxResults, limit };
19
+ }
@@ -0,0 +1,101 @@
1
+ /** Public report contracts only. Provider credentials and storage stay in the private service. */
2
+ import { z } from 'zod';
3
+ export declare const SEO_REPORT_HEADER = "X-GeoRanker-SEO-Tools";
4
+ export declare const SEO_REPORT_CATALOG = "reports-v1";
5
+ export declare const SEO_REPORT_KINDS: readonly ["rank_tracking", "onpage", "broken_links", "backlinks", "keyword_volume"];
6
+ export type SeoReportKind = typeof SEO_REPORT_KINDS[number];
7
+ export declare const SEO_INPUT_SCHEMAS: {
8
+ readonly create_rank_tracking_report: z.ZodObject<{
9
+ campaignName: z.ZodOptional<z.ZodString>;
10
+ reportName: z.ZodDefault<z.ZodString>;
11
+ targetDomain: z.ZodString;
12
+ keywords: z.ZodArray<z.ZodString>;
13
+ locations: z.ZodArray<z.ZodString>;
14
+ engines: z.ZodDefault<z.ZodArray<z.ZodString>>;
15
+ device: z.ZodDefault<z.ZodEnum<{
16
+ desktop: "desktop";
17
+ mobile: "mobile";
18
+ }>>;
19
+ trackTop: z.ZodDefault<z.ZodNumber>;
20
+ isRecurring: z.ZodDefault<z.ZodBoolean>;
21
+ scheduleDays: z.ZodOptional<z.ZodNumber>;
22
+ fetchKeywordData: z.ZodDefault<z.ZodBoolean>;
23
+ forceLive: z.ZodDefault<z.ZodBoolean>;
24
+ }, z.core.$strict>;
25
+ readonly get_rank_tracking_report: z.ZodObject<{
26
+ reportId: z.ZodString;
27
+ }, z.core.$strict>;
28
+ readonly update_rank_tracking_schedule: z.ZodObject<{
29
+ reportId: z.ZodString;
30
+ isRecurring: z.ZodBoolean;
31
+ scheduleDays: z.ZodOptional<z.ZodNumber>;
32
+ }, z.core.$strict>;
33
+ readonly create_onpage_report: z.ZodObject<{
34
+ campaignName: z.ZodOptional<z.ZodString>;
35
+ url: z.ZodString;
36
+ devices: z.ZodDefault<z.ZodArray<z.ZodEnum<{
37
+ desktop: "desktop";
38
+ mobile: "mobile";
39
+ }>>>;
40
+ isRecurring: z.ZodDefault<z.ZodLiteral<false>>;
41
+ forceLive: z.ZodDefault<z.ZodBoolean>;
42
+ }, z.core.$strict>;
43
+ readonly get_onpage_report: z.ZodObject<{
44
+ reportId: z.ZodString;
45
+ }, z.core.$strict>;
46
+ readonly create_broken_links_report: z.ZodObject<{
47
+ campaignName: z.ZodOptional<z.ZodString>;
48
+ url: z.ZodString;
49
+ scope: z.ZodDefault<z.ZodEnum<{
50
+ site: "site";
51
+ page: "page";
52
+ domain: "domain";
53
+ }>>;
54
+ maxDepth: z.ZodDefault<z.ZodNumber>;
55
+ maxPages: z.ZodDefault<z.ZodNumber>;
56
+ checkExternalLinks: z.ZodDefault<z.ZodLiteral<false>>;
57
+ forceLive: z.ZodDefault<z.ZodBoolean>;
58
+ }, z.core.$strict>;
59
+ readonly get_broken_links_report: z.ZodObject<{
60
+ reportId: z.ZodString;
61
+ }, z.core.$strict>;
62
+ readonly create_backlinks_report: z.ZodObject<{
63
+ campaignName: z.ZodOptional<z.ZodString>;
64
+ target: z.ZodString;
65
+ endpoint: z.ZodDefault<z.ZodEnum<{
66
+ backlinks: "backlinks";
67
+ summary: "summary";
68
+ referring_domains: "referring_domains";
69
+ anchors: "anchors";
70
+ history: "history";
71
+ timeseries_new_lost: "timeseries_new_lost";
72
+ competitors: "competitors";
73
+ domain_pages: "domain_pages";
74
+ }>>;
75
+ name: z.ZodOptional<z.ZodString>;
76
+ forceLive: z.ZodDefault<z.ZodBoolean>;
77
+ }, z.core.$strict>;
78
+ readonly get_backlinks_report: z.ZodObject<{
79
+ reportId: z.ZodString;
80
+ }, z.core.$strict>;
81
+ readonly create_keyword_volume_report: z.ZodObject<{
82
+ campaignName: z.ZodOptional<z.ZodString>;
83
+ name: z.ZodDefault<z.ZodString>;
84
+ keywords: z.ZodArray<z.ZodString>;
85
+ location: z.ZodOptional<z.ZodString>;
86
+ provider: z.ZodOptional<z.ZodString>;
87
+ endpoint: z.ZodOptional<z.ZodString>;
88
+ forceLive: z.ZodDefault<z.ZodBoolean>;
89
+ }, z.core.$strict>;
90
+ readonly get_keyword_volume_report: z.ZodObject<{
91
+ reportId: z.ZodString;
92
+ }, z.core.$strict>;
93
+ };
94
+ export type SeoToolName = keyof typeof SEO_INPUT_SCHEMAS;
95
+ export declare const SEO_TOOL_NAMES: SeoToolName[];
96
+ export declare function parseSeoInput(name: SeoToolName, input: unknown): Record<string, unknown>;
97
+ export declare function seoAdmissionUnits(name: SeoToolName, raw: unknown): number;
98
+ export declare const SEO_TOOL_DESCRIPTIONS: Record<SeoToolName, {
99
+ title: string;
100
+ description: string;
101
+ }>;
@@ -0,0 +1,105 @@
1
+ /** Public report contracts only. Provider credentials and storage stay in the private service. */
2
+ import { z } from 'zod';
3
+ import { AppError } from './errors.js';
4
+ export const SEO_REPORT_HEADER = 'X-GeoRanker-SEO-Tools';
5
+ export const SEO_REPORT_CATALOG = 'reports-v1';
6
+ export const SEO_REPORT_KINDS = ['rank_tracking', 'onpage', 'broken_links', 'backlinks', 'keyword_volume'];
7
+ const text = z.string().trim().min(1).max(500);
8
+ const publicUrl = z.string().trim().max(2048).url().regex(/^https?:\/\//i);
9
+ const forceLive = z.boolean().default(false).describe('Bypass completed-result reuse (seven days by default). Retain unresolved work instead of creating a duplicate report.');
10
+ const reportId = z.string().regex(/^seo_[a-f0-9-]{36}$/).describe('The MCP reportId returned by the matching create tool, not a provider report ID.');
11
+ const campaignName = z.string().trim().min(2).max(120).regex(/^[^\u0000-\u001f\u007f]+$/).optional().describe('Campaign name within your own account. Omit to use this installation’s default campaign. A name never grants access to another user’s campaign. Campaign assignment must be supported by the report provider.');
12
+ const scheduleDays = z.number().int().min(1).max(365).optional();
13
+ const lookup = z.object({ reportId }).strict();
14
+ export const SEO_INPUT_SCHEMAS = {
15
+ create_rank_tracking_report: z.object({
16
+ campaignName,
17
+ reportName: text.default('MCP rank tracking'),
18
+ targetDomain: text.describe('Public domain whose ranking is tracked.'),
19
+ keywords: z.array(text).min(1).max(200),
20
+ locations: z.array(z.string().trim().min(1).max(200)).min(1).max(50).describe('Location names, for example Bucharest, Romania. Every keyword is checked in every location and engine.'),
21
+ engines: z.array(z.string().trim().min(1).max(50)).min(1).max(10).default(['google']),
22
+ device: z.enum(['desktop', 'mobile']).default('desktop'),
23
+ trackTop: z.number().int().min(1).max(100).default(10),
24
+ isRecurring: z.boolean().default(false).describe('Enable provider-managed recurring work only when explicitly requested. Future runs can consume credits.'),
25
+ scheduleDays,
26
+ fetchKeywordData: z.boolean().default(false),
27
+ forceLive,
28
+ }).strict().refine(value => !value.isRecurring || value.scheduleDays !== undefined, { message: 'Set scheduleDays explicitly when enabling recurring tracking.', path: ['scheduleDays'] }),
29
+ get_rank_tracking_report: lookup,
30
+ update_rank_tracking_schedule: z.object({ reportId, isRecurring: z.boolean(), scheduleDays }).strict()
31
+ .refine(value => !value.isRecurring || value.scheduleDays !== undefined, { message: 'Set scheduleDays explicitly when enabling recurring tracking.', path: ['scheduleDays'] }),
32
+ create_onpage_report: z.object({
33
+ campaignName,
34
+ url: publicUrl,
35
+ devices: z.array(z.enum(['mobile', 'desktop'])).min(1).max(2).default(['mobile', 'desktop']),
36
+ isRecurring: z.literal(false).default(false),
37
+ forceLive,
38
+ }).strict(),
39
+ get_onpage_report: lookup,
40
+ create_broken_links_report: z.object({
41
+ campaignName,
42
+ url: publicUrl,
43
+ scope: z.enum(['site', 'page', 'domain']).default('site').describe('Use site for a bounded site crawl or page for analysis of links on the supplied page. The legacy domain value is a deprecated alias for site. Path-restricted crawling is unsupported; do not substitute a different coverage without the user choosing it.'),
44
+ maxDepth: z.number().int().min(1).max(10).default(2).describe('Maximum crawl depth; MCP limit 10.'),
45
+ maxPages: z.number().int().min(1).max(1000).default(20).describe('Maximum pages to crawl; MCP limit 1000. Each block of up to 100 requested pages counts as one shared admission unit (rounded up). Keep the user-requested crawl coverage.'),
46
+ checkExternalLinks: z.literal(false).default(false).describe('Return internal link and resource checks only. Output states the hostname boundary, excluded rows and actual crawl coverage.'),
47
+ forceLive,
48
+ }).strict(),
49
+ get_broken_links_report: lookup,
50
+ create_backlinks_report: z.object({
51
+ campaignName,
52
+ target: z.string().trim().min(1).max(2048).describe('Public domain or URL to analyze.'),
53
+ endpoint: z.enum(['summary', 'backlinks', 'referring_domains', 'anchors', 'history', 'timeseries_new_lost', 'competitors', 'domain_pages']).default('summary'),
54
+ name: text.optional(),
55
+ forceLive,
56
+ }).strict(),
57
+ get_backlinks_report: lookup,
58
+ create_keyword_volume_report: z.object({
59
+ campaignName,
60
+ name: text.default('MCP keyword volumes'),
61
+ keywords: z.array(text).min(1).max(200).describe('Keywords for the volume report; MCP limit 200.'),
62
+ location: z.string().trim().min(1).max(200).optional(),
63
+ provider: z.string().trim().min(1).max(100).optional().describe('Documented SEO API provider identifier. Omit to use the operator-configured volume provider.'),
64
+ endpoint: z.string().trim().min(1).max(100).optional().describe('Documented volume endpoint identifier. Omit to use the operator-configured volume endpoint.'),
65
+ forceLive,
66
+ }).strict(),
67
+ get_keyword_volume_report: lookup,
68
+ };
69
+ export const SEO_TOOL_NAMES = Object.keys(SEO_INPUT_SCHEMAS);
70
+ export function parseSeoInput(name, input) {
71
+ if (name === 'create_broken_links_report' && input && typeof input === 'object' && 'scope' in input && input.scope === 'path') {
72
+ throw new AppError('INVALID_INPUT', 'Broken-link scope "path" is unsupported. Choose "site" for a bounded site crawl or "page" to analyze links on the supplied page. Ask the user to choose the intended coverage; do not silently replace a path restriction.', undefined, { field: 'scope', allowedValues: ['site', 'page'], submissionUncertain: false });
73
+ }
74
+ const result = SEO_INPUT_SCHEMAS[name]?.safeParse(input);
75
+ if (!result?.success)
76
+ throw new AppError('INVALID_INPUT', 'Report arguments do not match the tool schema. Check required fields, report ID, batch limits and schedule.');
77
+ if (name === 'create_broken_links_report' && 'scope' in result.data && result.data.scope === 'domain')
78
+ return { ...result.data, scope: 'site' };
79
+ return result.data;
80
+ }
81
+ export function seoAdmissionUnits(name, raw) {
82
+ const input = parseSeoInput(name, raw);
83
+ if (name === 'create_rank_tracking_report')
84
+ return input.keywords.length * input.locations.length * input.engines.length;
85
+ if (name === 'create_broken_links_report')
86
+ return Math.ceil(input.maxPages / 100);
87
+ if (name === 'create_keyword_volume_report')
88
+ return input.keywords.length;
89
+ if (name === 'create_onpage_report')
90
+ return input.devices.length;
91
+ return 1;
92
+ }
93
+ export const SEO_TOOL_DESCRIPTIONS = {
94
+ create_rank_tracking_report: { title: 'Track rankings across keywords and locations', description: 'Create a domain rank report for multiple keywords, locations and engines. Requested checks are keywords × locations × engines and consume the shared allowance. Optional recurring tracking must be explicitly requested and enabled by the operator; future provider runs can consume credits. Use get_rank_tracking_report for the returned reportId.' },
95
+ get_rank_tracking_report: { title: 'Get rank tracking report', description: 'Retrieve an owned rank tracking report and its keyword results, without creating another report. Preserve checked coverage and provider timestamps; do not infer absent rankings.' },
96
+ update_rank_tracking_schedule: { title: 'Update rank tracking schedule', description: 'Enable, change or stop provider-managed recurring tracking for an owned report. Enabling requires an explicit interval and can cause future credit-consuming runs. This changes persistent report settings; use only when requested.' },
97
+ create_onpage_report: { title: 'Run Lighthouse on-page SEO analysis', description: 'Create a Lighthouse/PSI report for one public URL on mobile, desktop or both. This audits the specified page; it is not a site-wide crawl. Use get_onpage_report for the returned reportId.' },
98
+ get_onpage_report: { title: 'Get Lighthouse SEO report', description: 'Retrieve an owned Lighthouse/PSI summary with category scores, audit findings, explanations, metrics and completion status for each requested device. Missing devices are named explicitly. Embedded screenshots and detailed audit/resource tables are omitted and labeled. Does not run a new audit.' },
99
+ create_broken_links_report: { title: 'Analyze broken internal links', description: 'Analyze internal links with a bounded site crawl (scope site, the default) or links on the supplied page (scope page). The old domain scope is accepted as an alias for site; path-restricted crawling is unsupported. External-link checking is disabled. Each block of up to 100 requested pages uses one shared admission unit. Keep the user-requested coverage; do not silently shrink it to fit an allowance or change a path restriction to another scope. Use get_broken_links_report for the returned reportId.' },
100
+ get_broken_links_report: { title: 'Get broken internal links report', description: 'Retrieve internal link and resource checks without re-running the crawl. Coverage separates returned internal rows from provider totals and excluded external rows. A completed bounded crawl does not establish full-site navigation coverage; HTTP 403 means access denied, not necessarily a missing page.' },
101
+ create_backlinks_report: { title: 'Create backlink intelligence report', description: 'Request backlink summary, backlinks, referring domains, anchors, history, new/lost timeseries, competitors or domain pages for a public target. New reports can consume provider credits. Use get_backlinks_report for the returned reportId.' },
102
+ get_backlinks_report: { title: 'Get backlink report', description: 'Retrieve an owned backlink report and provider results without creating another report. Preserve report type, timestamps and actual coverage. Ready means the requested response is available; coverage states returned rows versus provider totals. These tools do not export additional provider pages.' },
103
+ create_keyword_volume_report: { title: 'Research keyword search volumes', description: 'Create a keyword report using the configured volume provider and endpoint, or explicitly supplied documented identifiers. Supports multiple keywords and an optional location. New work can consume credits. Use get_keyword_volume_report for the returned reportId.' },
104
+ get_keyword_volume_report: { title: 'Get keyword volume report', description: 'Retrieve an owned keyword report and its provider data without creating another report. Only describe volume, date, units and location actually returned by the provider.' },
105
+ };
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type ProductProfile, type SearchServiceLike } from './product-contract.js';
3
+ export { SERVER_VERSION } from './product-contract.js';
4
+ export type { SearchInput, SearchResultInput, FetchPageInput, SearchServiceLike } from './product-contract.js';
5
+ export declare function createServer(service: SearchServiceLike, profile?: ProductProfile, options?: {
6
+ seoReports?: boolean;
7
+ }): McpServer;