@digestseo/mcp-gsc 0.4.33 → 0.4.35

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 CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.35] - 2026-09-24
11
+
12
+ ### Fixed
13
+ - Search Analytics pagination now treats provider responses shorter than the requested page size as terminal, matching Google's documented contract and avoiding false continuations or unnecessary follow-up requests across direct and higher-level workflows.
14
+ - `insights.page_queries` now rejects exact page URLs outside the supplied Search Console property before reserving local Search Analytics safety budget, resolving Google credentials, or calling the provider, preventing misleading empty cross-property drilldowns.
15
+
16
+ ## [0.4.34] - 2026-09-24
17
+
18
+ ### Fixed
19
+ - `analytics.query` now rejects documented-invalid cross-field request combinations before reserving local Search Analytics safety budget or resolving Google credentials, while retaining the lower-level provider compatibility guard for internal callers.
20
+ - `indexing.status`, `indexing.request`, and `indexing.remove` now reject URLs outside the supplied Search Console property before reserving local safety budget, resolving Google credentials, or calling Search Console for owner authorization.
21
+
10
22
  ## [0.4.33] - 2026-09-24
11
23
 
12
24
  ### Changed
package/README.md CHANGED
@@ -66,6 +66,8 @@ Codex CLI and the Codex IDE extension share MCP configuration, so registering th
66
66
 
67
67
  When this repository is installed as a Cursor plugin, Cursor also auto-discovers three bundled Agent Skills that compose the MCP tools into evidence-safe workflows: `gsc-weekly-review`, `gsc-indexing-triage`, and `gsc-search-opportunities`. The **Add to Cursor** button above installs the MCP connection only; the bundled skills are part of the repository's Cursor plugin package.
68
68
 
69
+ The button and plugin MCP configuration above are for Cursor running on the same machine as the npm launcher. **Cursor Cloud Agents run in isolated cloud VMs, so they cannot reach `127.0.0.1` on your computer.** For Cloud Agents, deploy your own externally reachable HTTPS Worker and add its `/mcp` URL from the MCP controls at [cursor.com/agents](https://cursor.com/agents); Cursor supports HTTP MCP servers and OAuth there. Do not reuse the loopback URL for a Cloud Agent.
70
+
69
71
  **GitHub Copilot CLI**
70
72
 
71
73
  ```bash
@@ -89,7 +91,9 @@ The Gemini CLI extension bundles the verified local loopback MCP configuration p
89
91
 
90
92
  Start the npm launcher before using either install button or the Copilot plugin. Complete its Google OAuth flow on first use.
91
93
 
92
- **ChatGPT** — ChatGPT cannot connect directly to this loopback `127.0.0.1` endpoint. Use OpenAI's **Secure MCP Tunnel** for the local launcher, or deploy your own remote Cloudflare Worker and add that `/mcp` URL as a custom MCP app in Developer mode; see [Connect in your AI client](#connect-in-your-ai-client) below.
94
+ **ChatGPT** — ChatGPT cannot connect directly to this loopback `127.0.0.1` endpoint. For private/local use, create an OpenAI **Secure MCP Tunnel**, then run `tunnel-client` on the same machine as the launcher so it can reach `http://127.0.0.1:8080/mcp`; when creating the ChatGPT app, choose the **Tunnel** connection and select that tunnel. The tunnel setup requires an OpenAI `tunnel_id` and runtime API key; see OpenAI's [Secure MCP Tunnel guide](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels). The tunnel keeps the MCP server private and is intended for private/developer-mode connectivity, not public plugin distribution.
95
+
96
+ Alternatively, deploy your own remote Cloudflare Worker and add its HTTPS `/mcp` URL as a custom MCP app in Developer mode; see [Connect in your AI client](#connect-in-your-ai-client) below.
93
97
 
94
98
  A self-hostable [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for **Google Search Console**. Connect it to Claude.ai, Cursor, ChatGPT, or any MCP-compatible client and ask your AI assistant about your site's organic search performance — impressions, clicks, top queries, index status, and sitemap health — straight from your own Google account.
95
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
package/src/google.ts CHANGED
@@ -785,10 +785,9 @@ export async function querySearchAnalytics(
785
785
 
786
786
  /**
787
787
  * Fetches Search Analytics rows in 25,000-row pages for higher-level tools.
788
- * Google's documented pagination contract uses an explicit empty response as
789
- * the terminal page, so a short non-empty page is not treated as exhaustion.
790
- * A full final page conservatively reports localLimitReached because the API
791
- * can have more rows even when Search Console's own internal limits apply.
788
+ * Google's documented pagination contract treats a response shorter than the
789
+ * requested rowLimit as exhausted. An exactly full final page still requires
790
+ * a following empty-page probe unless the local safety ceiling is reached.
792
791
  */
793
792
  export async function querySearchAnalyticsPaginated(
794
793
  accessToken: string,
@@ -820,7 +819,7 @@ export async function querySearchAnalyticsPaginated(
820
819
 
821
820
  const acceptedRows = page.rows.slice(0, requestedRows);
822
821
  rows.push(...acceptedRows);
823
- if (page.rows.length === 0) {
822
+ if (page.rows.length < requestedRows) {
824
823
  return { rows, pagesFetched, localLimitReached: false };
825
824
  }
826
825
  if (rows.length >= maximumRows || page.rows.length > requestedRows) {
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  getIndexingNotificationMetadata,
35
35
  processPerformanceComparison,
36
36
  assertSearchAnalyticsFilterCompatible,
37
+ assertSearchAnalyticsQueryCompatible,
37
38
  type PaginatedSearchAnalyticsResult,
38
39
  } from './google';
39
40
  import {
@@ -1387,12 +1388,8 @@ class GscMcpRuntime {
1387
1388
  dimension_filter_groups,
1388
1389
  }) => {
1389
1390
  assertDateRange(start_date, end_date);
1390
- const googleId = this.requireGoogleId();
1391
- const rateLimitError = await this.rateLimitError(googleId, 'analytics.query');
1392
- if (rateLimitError) return rateLimitError;
1393
- const accessToken = await this.getAccessToken(googleId);
1394
1391
  const sourceRowLimit = Math.min(row_limit, MAX_DIRECT_SOURCE_ROWS);
1395
- const response = await querySearchAnalytics(accessToken, site_url, {
1392
+ const query = {
1396
1393
  startDate: start_date,
1397
1394
  endDate: end_date,
1398
1395
  dimensions,
@@ -1404,12 +1401,17 @@ class GscMcpRuntime {
1404
1401
  ...(dimension_filter_groups !== undefined
1405
1402
  ? { dimensionFilterGroups: dimension_filter_groups }
1406
1403
  : {}),
1407
- });
1408
- // Search Analytics pagination is complete only after Google returns an
1409
- // explicit empty page. A short non-empty page can still be followed by
1410
- // later rows, so preserve a continuation for dimensioned queries.
1404
+ };
1405
+ assertSearchAnalyticsQueryCompatible(query);
1406
+ const googleId = this.requireGoogleId();
1407
+ const rateLimitError = await this.rateLimitError(googleId, 'analytics.query');
1408
+ if (rateLimitError) return rateLimitError;
1409
+ const accessToken = await this.getAccessToken(googleId);
1410
+ const response = await querySearchAnalytics(accessToken, site_url, query);
1411
+ // Google documents a short page as terminal. An exactly full page can
1412
+ // still have a following page, so expose continuation only then.
1411
1413
  const sourceMayHaveMore =
1412
- dimensions.length > 0 && response.rows.length > 0;
1414
+ dimensions.length > 0 && response.rows.length === sourceRowLimit;
1413
1415
  const bounded = windowSourceRows(
1414
1416
  response.rows,
1415
1417
  start_row,
@@ -1460,10 +1462,10 @@ class GscMcpRuntime {
1460
1462
  'insights.page_queries',
1461
1463
  {
1462
1464
  title: 'Find queries for a page',
1463
- description: 'For one exact page URL, return the Search Console queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover and Google News are intentionally unavailable because those reports do not expose query data.',
1465
+ description: 'For one exact page URL within the supplied Search Console property, return the queries that produced impressions for it over a date range. This wraps an exact page dimension filter so agents do not need to construct analytics.query filter groups manually. Exact page matching is case-sensitive in Search Console. Use row_limit and start_row to page through bounded results; Search Console can still omit anonymized queries. Google Discover and Google News are intentionally unavailable because those reports do not expose query data.',
1464
1466
  inputSchema: {
1465
1467
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1466
- page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. Search Console exact page filters are case-sensitive.'),
1468
+ page_url: z.string().url().describe('Exact fully-qualified page URL to filter on, e.g. https://example.com/guides/seo/. The URL must belong to site_url; Search Console exact page filters are case-sensitive.'),
1467
1469
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1468
1470
  searchConsoleDateDescription('Start date (inclusive) in YYYY-MM-DD format.'),
1469
1471
  ),
@@ -1495,6 +1497,7 @@ class GscMcpRuntime {
1495
1497
  },
1496
1498
  async ({ site_url, page_url, start_date, end_date, search_type, row_limit = 100, start_row = 0 }) => {
1497
1499
  assertDateRange(start_date, end_date);
1500
+ assertUrlWithinSearchConsoleProperty(page_url, site_url, 'page_url');
1498
1501
  const googleId = this.requireGoogleId();
1499
1502
  const rateLimitError = await this.rateLimitError(googleId, 'insights.page_queries');
1500
1503
  if (rateLimitError) return rateLimitError;
@@ -1957,6 +1960,7 @@ class GscMcpRuntime {
1957
1960
  annotations: READ_ONLY_ANNOTATIONS,
1958
1961
  },
1959
1962
  async ({ site_url, url }) => {
1963
+ assertUrlWithinSearchConsoleProperty(url, site_url);
1960
1964
  const googleId = this.requireGoogleId();
1961
1965
  const rateLimitError = await this.rateLimitError(googleId, 'indexing.status');
1962
1966
  if (rateLimitError) return rateLimitError;
@@ -2000,6 +2004,7 @@ class GscMcpRuntime {
2000
2004
  annotations: WRITE_TOOL_ANNOTATIONS['indexing.request'],
2001
2005
  },
2002
2006
  async ({ site_url, url }) => {
2007
+ assertUrlWithinSearchConsoleProperty(url, site_url);
2003
2008
  const googleId = this.requireGoogleId();
2004
2009
  const rateLimitError = await this.rateLimitError(googleId, 'indexing.request');
2005
2010
  if (rateLimitError) return rateLimitError;
@@ -2041,6 +2046,7 @@ class GscMcpRuntime {
2041
2046
  annotations: WRITE_TOOL_ANNOTATIONS['indexing.remove'],
2042
2047
  },
2043
2048
  async ({ site_url, url }) => {
2049
+ assertUrlWithinSearchConsoleProperty(url, site_url);
2044
2050
  const googleId = this.requireGoogleId();
2045
2051
  const rateLimitError = await this.rateLimitError(googleId, 'indexing.remove');
2046
2052
  if (rateLimitError) return rateLimitError;
@@ -2118,7 +2124,7 @@ class GscMcpRuntime {
2118
2124
  pages,
2119
2125
  start_row,
2120
2126
  row_limit,
2121
- response.rows.length > 0,
2127
+ response.rows.length === sourceRowLimit,
2122
2128
  start_row + sourceRowLimit,
2123
2129
  );
2124
2130