@digestseo/mcp-gsc 0.4.5 → 0.4.6
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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/package.json +2 -2
- package/src/google.ts +149 -11
- package/src/index.ts +14 -1
- package/src/indexing-property-authorization.ts +27 -10
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.6] - 2026-09-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Package metadata and install guidance now advertise the actual Node.js ranges accepted by bundled runtime dependencies (`^22.18.0 || >=24.11.0`) instead of claiming all Node.js 22+ releases are supported.
|
|
12
|
+
- Indexing API eligibility preflight now recognizes static JSON-LD, Microdata, and RDFa type markup, including schema.org URL type spellings, instead of rejecting otherwise eligible static pages solely because they do not use the narrow JSON-LD form previously detected.
|
|
13
|
+
- URL Inspection now rejects malformed/non-HTTP(S) URLs and URLs outside the supplied Search Console property locally before consuming the server's inspection safety budget or calling Google.
|
|
14
|
+
|
|
8
15
|
## [0.4.5] - 2026-09-21
|
|
9
16
|
|
|
10
17
|
### Fixed
|
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ Read-write mode requests the Google Search Console read-write and Indexing API s
|
|
|
89
89
|
|
|
90
90
|
Large analytics responses are bounded at the MCP boundary instead of being generated and then discarded by clients with structured-content limits. List-style tools expose `start_row` / `next_start_row` where applicable; ranked analytical tools expose `limit`, `start_row`, and `result_page` metadata including `has_more`, `truncated`, and `byte_limit_reached`. Continue paging while `has_more` is true rather than assuming one response is complete.
|
|
91
91
|
|
|
92
|
-
> **`indexing.request` eligibility.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks
|
|
92
|
+
> **`indexing.request` eligibility.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks static JSON-LD, Microdata, and RDFa markup for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
|
|
93
93
|
|
|
94
94
|
## What you can ask
|
|
95
95
|
|
package/SETUP.md
CHANGED
|
@@ -10,7 +10,7 @@ By the end you'll have a Worker at `https://<your-worker>.workers.dev/mcp` that
|
|
|
10
10
|
|
|
11
11
|
- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free Workers plan is enough to start).
|
|
12
12
|
- A [Google account](https://accounts.google.com/) with access to the Search Console properties you want to query.
|
|
13
|
-
- Node.js 22+
|
|
13
|
+
- Node.js 22.18+ within the 22.x line, or Node.js 24.11+ with npm. The bundled runtime dependency graph includes Babel 8 packages with this stricter Node support range.
|
|
14
14
|
- The Wrangler CLI — installed for you by `npm install`; invoke it with `npx wrangler …`.
|
|
15
15
|
|
|
16
16
|
Clone and install:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digestseo/mcp-gsc",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "scripts/glama-start.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -87,6 +87,6 @@
|
|
|
87
87
|
"vitest": "^4.1.11"
|
|
88
88
|
},
|
|
89
89
|
"engines": {
|
|
90
|
-
"node": "
|
|
90
|
+
"node": "^22.18.0 || >=24.11.0"
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/google.ts
CHANGED
|
@@ -985,6 +985,15 @@ const INDEXING_SCOPE_MESSAGE =
|
|
|
985
985
|
'and livestream pages (BroadcastEvent structured data inside VideoObject). ' +
|
|
986
986
|
'It is not available for general webpage submission.';
|
|
987
987
|
|
|
988
|
+
function normalizeSchemaType(value: string): string {
|
|
989
|
+
const trimmed = value.trim().replace(/[\/#]+$/, '');
|
|
990
|
+
const schemaUrl = trimmed.match(
|
|
991
|
+
/^https?:\/\/(?:www\.)?schema\.org\/([^/?#\s]+)$/i,
|
|
992
|
+
);
|
|
993
|
+
if (schemaUrl) return schemaUrl[1];
|
|
994
|
+
return trimmed;
|
|
995
|
+
}
|
|
996
|
+
|
|
988
997
|
function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
989
998
|
if (Array.isArray(node)) {
|
|
990
999
|
for (const item of node) collectSchemaTypes(item, types);
|
|
@@ -993,9 +1002,11 @@ function collectSchemaTypes(node: unknown, types: Set<string>): void {
|
|
|
993
1002
|
if (!node || typeof node !== 'object') return;
|
|
994
1003
|
const obj = node as Record<string, unknown>;
|
|
995
1004
|
const type = obj['@type'];
|
|
996
|
-
if (typeof type === 'string') types.add(type);
|
|
1005
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
997
1006
|
else if (Array.isArray(type)) {
|
|
998
|
-
for (const t of type)
|
|
1007
|
+
for (const t of type) {
|
|
1008
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1009
|
+
}
|
|
999
1010
|
}
|
|
1000
1011
|
for (const [key, value] of Object.entries(obj)) {
|
|
1001
1012
|
if (key === '@type') continue;
|
|
@@ -1010,9 +1021,11 @@ function containsBroadcastEvent(node: unknown): boolean {
|
|
|
1010
1021
|
const obj = node as Record<string, unknown>;
|
|
1011
1022
|
const types = new Set<string>();
|
|
1012
1023
|
const type = obj['@type'];
|
|
1013
|
-
if (typeof type === 'string') types.add(type);
|
|
1024
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1014
1025
|
else if (Array.isArray(type)) {
|
|
1015
|
-
for (const t of type)
|
|
1026
|
+
for (const t of type) {
|
|
1027
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1028
|
+
}
|
|
1016
1029
|
}
|
|
1017
1030
|
if (types.has('BroadcastEvent')) return true;
|
|
1018
1031
|
return Object.values(obj).some(containsBroadcastEvent);
|
|
@@ -1025,14 +1038,134 @@ function hasEligibleVideoBroadcast(node: unknown): boolean {
|
|
|
1025
1038
|
const obj = node as Record<string, unknown>;
|
|
1026
1039
|
const types = new Set<string>();
|
|
1027
1040
|
const type = obj['@type'];
|
|
1028
|
-
if (typeof type === 'string') types.add(type);
|
|
1041
|
+
if (typeof type === 'string') types.add(normalizeSchemaType(type));
|
|
1029
1042
|
else if (Array.isArray(type)) {
|
|
1030
|
-
for (const t of type)
|
|
1043
|
+
for (const t of type) {
|
|
1044
|
+
if (typeof t === 'string') types.add(normalizeSchemaType(t));
|
|
1045
|
+
}
|
|
1031
1046
|
}
|
|
1032
1047
|
if (types.has('VideoObject') && containsBroadcastEvent(obj)) return true;
|
|
1033
1048
|
return Object.values(obj).some(hasEligibleVideoBroadcast);
|
|
1034
1049
|
}
|
|
1035
1050
|
|
|
1051
|
+
const VOID_HTML_TAGS = new Set([
|
|
1052
|
+
'area',
|
|
1053
|
+
'base',
|
|
1054
|
+
'br',
|
|
1055
|
+
'col',
|
|
1056
|
+
'embed',
|
|
1057
|
+
'hr',
|
|
1058
|
+
'img',
|
|
1059
|
+
'input',
|
|
1060
|
+
'link',
|
|
1061
|
+
'meta',
|
|
1062
|
+
'param',
|
|
1063
|
+
'source',
|
|
1064
|
+
'track',
|
|
1065
|
+
'wbr',
|
|
1066
|
+
]);
|
|
1067
|
+
|
|
1068
|
+
function htmlAttribute(attributes: string, name: string): string | undefined {
|
|
1069
|
+
const pattern = new RegExp(
|
|
1070
|
+
`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`,
|
|
1071
|
+
'i',
|
|
1072
|
+
);
|
|
1073
|
+
const match = pattern.exec(attributes);
|
|
1074
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function isSchemaVocabulary(value: string | undefined): boolean {
|
|
1078
|
+
return Boolean(value && /^https?:\/\/(?:www\.)?schema\.org\/?$/i.test(value.trim()));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function explicitSchemaType(value: string): string | undefined {
|
|
1082
|
+
const trimmed = value.trim();
|
|
1083
|
+
if (/^https?:\/\/(?:www\.)?schema\.org\//i.test(trimmed)) {
|
|
1084
|
+
return normalizeSchemaType(trimmed);
|
|
1085
|
+
}
|
|
1086
|
+
return undefined;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function htmlStructuredTypes(
|
|
1090
|
+
attributes: string,
|
|
1091
|
+
schemaVocabularyInScope: boolean,
|
|
1092
|
+
): Set<string> {
|
|
1093
|
+
const types = new Set<string>();
|
|
1094
|
+
|
|
1095
|
+
const itemType = htmlAttribute(attributes, 'itemtype');
|
|
1096
|
+
if (itemType) {
|
|
1097
|
+
for (const token of itemType.split(/\s+/)) {
|
|
1098
|
+
const normalized = explicitSchemaType(token);
|
|
1099
|
+
if (normalized) types.add(normalized);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const rdfaType = htmlAttribute(attributes, 'typeof');
|
|
1104
|
+
if (rdfaType) {
|
|
1105
|
+
for (const token of rdfaType.split(/\s+/)) {
|
|
1106
|
+
const explicit = explicitSchemaType(token);
|
|
1107
|
+
if (explicit) types.add(explicit);
|
|
1108
|
+
else if (schemaVocabularyInScope && token.trim()) {
|
|
1109
|
+
types.add(normalizeSchemaType(token));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return types;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Recognize static Microdata/RDFa eligibility without turning this preflight
|
|
1119
|
+
* into a general-purpose HTML validator. The stack is only used to enforce
|
|
1120
|
+
* Google's VideoObject -> BroadcastEvent nesting requirement.
|
|
1121
|
+
*/
|
|
1122
|
+
function hasEligibleHtmlStructuredData(html: string): boolean {
|
|
1123
|
+
const staticMarkup = html
|
|
1124
|
+
.replace(/<!--(?:[\s\S]*?)-->/g, '')
|
|
1125
|
+
.replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
|
|
1126
|
+
const stack: Array<{
|
|
1127
|
+
tag: string;
|
|
1128
|
+
schemaVocabulary: boolean;
|
|
1129
|
+
videoObjectInScope: boolean;
|
|
1130
|
+
}> = [];
|
|
1131
|
+
const tagPattern = /<\s*(\/?)\s*([a-zA-Z][\w:-]*)([^>]*)>/g;
|
|
1132
|
+
let match: RegExpExecArray | null;
|
|
1133
|
+
|
|
1134
|
+
while ((match = tagPattern.exec(staticMarkup)) !== null) {
|
|
1135
|
+
const closing = match[1] === '/';
|
|
1136
|
+
const tag = match[2].toLowerCase();
|
|
1137
|
+
const attributes = match[3];
|
|
1138
|
+
|
|
1139
|
+
if (closing) {
|
|
1140
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1141
|
+
if (stack[index].tag === tag) {
|
|
1142
|
+
stack.length = index;
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const parent = stack.at(-1);
|
|
1150
|
+
const schemaVocabulary =
|
|
1151
|
+
isSchemaVocabulary(htmlAttribute(attributes, 'vocab')) ||
|
|
1152
|
+
parent?.schemaVocabulary === true;
|
|
1153
|
+
const types = htmlStructuredTypes(attributes, schemaVocabulary);
|
|
1154
|
+
if (types.has('JobPosting')) return true;
|
|
1155
|
+
|
|
1156
|
+
const videoObjectInScope =
|
|
1157
|
+
types.has('VideoObject') || parent?.videoObjectInScope === true;
|
|
1158
|
+
if (types.has('BroadcastEvent') && videoObjectInScope) return true;
|
|
1159
|
+
|
|
1160
|
+
const selfClosing = /\/\s*$/.test(attributes);
|
|
1161
|
+
if (!selfClosing && !VOID_HTML_TAGS.has(tag)) {
|
|
1162
|
+
stack.push({ tag, schemaVocabulary, videoObjectInScope });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1036
1169
|
export interface IndexingEligibility {
|
|
1037
1170
|
eligible: boolean;
|
|
1038
1171
|
reason?: string;
|
|
@@ -1112,9 +1245,9 @@ async function readBoundedResponseText(response: Response): Promise<string> {
|
|
|
1112
1245
|
/**
|
|
1113
1246
|
* Checks whether `url` carries the structured data Google's Indexing API
|
|
1114
1247
|
* currently supports (JobPosting, or BroadcastEvent nested in VideoObject),
|
|
1115
|
-
* by inspecting
|
|
1116
|
-
* parsed, eligibility can't be confirmed and the
|
|
1117
|
-
* ineligible rather than silently submitted.
|
|
1248
|
+
* by inspecting static JSON-LD, Microdata, and RDFa markup. Best-effort: if
|
|
1249
|
+
* the page can't be fetched or parsed, eligibility can't be confirmed and the
|
|
1250
|
+
* URL is treated as ineligible rather than silently submitted.
|
|
1118
1251
|
*/
|
|
1119
1252
|
export async function checkIndexingEligibility(
|
|
1120
1253
|
url: string,
|
|
@@ -1193,7 +1326,8 @@ export async function checkIndexingEligibility(
|
|
|
1193
1326
|
|
|
1194
1327
|
const types = new Set<string>();
|
|
1195
1328
|
let hasVideoBroadcast = false;
|
|
1196
|
-
const scriptPattern =
|
|
1329
|
+
const scriptPattern =
|
|
1330
|
+
/<script\b[^>]*\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
1197
1331
|
let match: RegExpExecArray | null;
|
|
1198
1332
|
while ((match = scriptPattern.exec(html)) !== null) {
|
|
1199
1333
|
try {
|
|
@@ -1205,7 +1339,11 @@ export async function checkIndexingEligibility(
|
|
|
1205
1339
|
}
|
|
1206
1340
|
}
|
|
1207
1341
|
|
|
1208
|
-
if (
|
|
1342
|
+
if (
|
|
1343
|
+
types.has('JobPosting') ||
|
|
1344
|
+
hasVideoBroadcast ||
|
|
1345
|
+
hasEligibleHtmlStructuredData(html)
|
|
1346
|
+
) {
|
|
1209
1347
|
return { eligible: true };
|
|
1210
1348
|
}
|
|
1211
1349
|
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
import {
|
|
71
71
|
assertIndexingRequestUrl,
|
|
72
72
|
assertIndexingUrlAuthorized,
|
|
73
|
+
assertUrlWithinSearchConsoleProperty,
|
|
73
74
|
} from './indexing-property-authorization';
|
|
74
75
|
import {
|
|
75
76
|
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
@@ -588,7 +589,7 @@ const TOOL_CATALOG = [
|
|
|
588
589
|
{
|
|
589
590
|
name: 'indexing.request',
|
|
590
591
|
description:
|
|
591
|
-
'Request
|
|
592
|
+
'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool.',
|
|
592
593
|
},
|
|
593
594
|
{
|
|
594
595
|
name: 'indexing.list_pages',
|
|
@@ -759,6 +760,11 @@ class GscMcpRuntime {
|
|
|
759
760
|
},
|
|
760
761
|
async ({ site_url, inspection_url, language_code }) => {
|
|
761
762
|
const googleId = this.requireGoogleId();
|
|
763
|
+
assertUrlWithinSearchConsoleProperty(
|
|
764
|
+
inspection_url,
|
|
765
|
+
site_url,
|
|
766
|
+
'inspection_url',
|
|
767
|
+
);
|
|
762
768
|
const rateLimitError = await this.rateLimitError(googleId, 'urls.inspect');
|
|
763
769
|
if (rateLimitError) return rateLimitError;
|
|
764
770
|
const accessToken = await this.getAccessToken(googleId);
|
|
@@ -801,6 +807,13 @@ class GscMcpRuntime {
|
|
|
801
807
|
},
|
|
802
808
|
async ({ site_url, inspection_urls, language_code }) => {
|
|
803
809
|
const googleId = this.requireGoogleId();
|
|
810
|
+
for (const inspectionUrl of inspection_urls) {
|
|
811
|
+
assertUrlWithinSearchConsoleProperty(
|
|
812
|
+
inspectionUrl,
|
|
813
|
+
site_url,
|
|
814
|
+
'inspection_url',
|
|
815
|
+
);
|
|
816
|
+
}
|
|
804
817
|
const rateLimitError = await this.rateLimitError(
|
|
805
818
|
googleId,
|
|
806
819
|
'urls.inspect_many',
|
|
@@ -76,6 +76,32 @@ function isWithinDomainProperty(target: URL, domain: string): boolean {
|
|
|
76
76
|
return target.hostname === domain || target.hostname.endsWith(`.${domain}`);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Confirms that a fully-qualified HTTP(S) URL belongs to the supplied Search
|
|
81
|
+
* Console property. This is shared by URL Inspection and Indexing API guards
|
|
82
|
+
* so both paths use identical URL-prefix and Domain-property semantics.
|
|
83
|
+
*/
|
|
84
|
+
export function assertUrlWithinSearchConsoleProperty(
|
|
85
|
+
url: string,
|
|
86
|
+
siteUrl: string,
|
|
87
|
+
fieldName = 'url',
|
|
88
|
+
): void {
|
|
89
|
+
const target = parseHttpUrl(url, fieldName);
|
|
90
|
+
const normalizedSiteUrl = normalizeSiteUrl(siteUrl);
|
|
91
|
+
|
|
92
|
+
if (normalizedSiteUrl.startsWith(DOMAIN_PROPERTY_PREFIX)) {
|
|
93
|
+
if (isWithinDomainProperty(target, parseDomainProperty(normalizedSiteUrl))) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
} else if (isWithinUrlPrefix(target, new URL(normalizedSiteUrl))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${fieldName} must belong to the site_url Search Console property.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
/**
|
|
80
106
|
* Confirms that an Indexing API URL is contained by an owner-level Search
|
|
81
107
|
* Console property available to the authenticated account. URL parsing avoids
|
|
@@ -86,7 +112,6 @@ export function assertIndexingUrlAuthorized(
|
|
|
86
112
|
siteUrl: string,
|
|
87
113
|
sites: SearchConsoleSite[],
|
|
88
114
|
): void {
|
|
89
|
-
const target = parseHttpUrl(url, 'url');
|
|
90
115
|
const normalizedSiteUrl = normalizeSiteUrl(siteUrl);
|
|
91
116
|
const authorizedSite = sites.find((site) => {
|
|
92
117
|
if (site.permissionLevel !== SITE_OWNER_PERMISSION) return false;
|
|
@@ -103,15 +128,7 @@ export function assertIndexingUrlAuthorized(
|
|
|
103
128
|
);
|
|
104
129
|
}
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
if (isWithinDomainProperty(target, parseDomainProperty(normalizedSiteUrl))) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
} else if (isWithinUrlPrefix(target, new URL(normalizedSiteUrl))) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
throw new Error('url must belong to the authorized site_url Search Console property.');
|
|
131
|
+
assertUrlWithinSearchConsoleProperty(url, normalizedSiteUrl);
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
export function assertIndexingRequestUrl(url: string): void {
|