8.9 KiB
Google Search Console MCP Server — Architecture Spec
Overview
A TypeScript MCP server for Google Search Console with:
- 22 tools across 4 categories
- 5 interactive MCP Apps (structured content UI)
- Full tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
- Lazy loading via gateway tools + discover_tools meta-tool
- OAuth 2.0 + Service Account auth
- Response caching, rate limiting, server-side date handling
Project Structure
google-console-mcp/
├── src/
│ ├── index.ts # Entry point — creates server, connects transport
│ ├── server.ts # MCP server setup, lazy loading registry, tool/app registration
│ ├── auth/
│ │ ├── index.ts # Auth manager — picks OAuth or Service Account
│ │ ├── oauth.ts # OAuth 2.0 flow (personal accounts)
│ │ └── service-account.ts # Service account auth (automated/CI)
│ ├── tools/
│ │ ├── types.ts # Shared tool module interface
│ │ ├── discovery.ts # list_properties, get_property_details, verify_ownership, discover_tools
│ │ ├── analytics.ts # search_analytics, compare_periods, top_movers, mobile_vs_desktop, country_breakdown
│ │ ├── intelligence.ts # detect_quick_wins, keyword_cannibalization, content_decay_detection, query_clustering
│ │ ├── indexing.ts # inspect_url, batch_inspect_urls, request_indexing
│ │ ├── sitemaps.ts # list_sitemaps, get_sitemap, submit_sitemap, delete_sitemap
│ │ └── management.ts # add_property, remove_property
│ ├── apps/
│ │ ├── types.ts # App registration interface
│ │ ├── dashboard.ts # Performance Dashboard app
│ │ ├── quick-wins.ts # Quick Wins Board app
│ │ ├── url-health.ts # URL Health Inspector app
│ │ ├── cannibalization.ts # Keyword Cannibalization Map app
│ │ └── content-decay.ts # Content Decay Tracker app
│ ├── lib/
│ │ ├── gsc-client.ts # Google Search Console API wrapper (googleapis)
│ │ ├── cache.ts # In-memory response cache with TTL
│ │ ├── rate-limit.ts # API quota management (1200 queries/min default)
│ │ └── date-utils.ts # Server-side date calculations (prevents LLM hallucination)
│ └── schemas/
│ ├── analytics.ts # Zod schemas for analytics tools
│ ├── indexing.ts # Zod schemas for indexing tools
│ ├── sitemaps.ts # Zod schemas for sitemap tools
│ ├── intelligence.ts # Zod schemas for intelligence tools
│ └── management.ts # Zod schemas for management tools
├── package.json
├── tsconfig.json
└── README.md
Tool Module Interface
Every tool file in src/tools/ MUST export this interface:
// src/tools/types.ts
import { z } from 'zod';
export interface ToolAnnotations {
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
}
export interface ToolDefinition {
name: string;
description: string;
category: 'discovery' | 'analytics' | 'intelligence' | 'indexing' | 'sitemaps' | 'management';
annotations: ToolAnnotations;
inputSchema: z.ZodType<any>;
handler: (args: any, gscClient: GSCClient) => Promise<ToolResult>;
}
export interface ToolResult {
content: Array<{
type: 'text' | 'image' | 'resource';
text?: string;
data?: string;
mimeType?: string;
}>;
structuredContent?: Record<string, any>; // For MCP Apps
isError?: boolean;
}
export interface ToolModule {
tools: ToolDefinition[];
}
App Interface
Each app in src/apps/ exports:
// src/apps/types.ts
export interface AppDefinition {
/** Tool name that triggers this app */
toolName: string;
/** Resource URI for the app HTML */
resourceUri: string;
/** The bundled HTML string (self-contained, no external deps) */
html: string;
/** Description shown in tool listing */
description: string;
}
Apps are self-contained HTML strings with inline CSS and JS. They receive data via
the tool result's structuredContent and render it client-side. Use the MCP Apps SDK
pattern: app.callServerTool() for any server communication.
Lazy Loading
The server starts with only 4 tools registered:
list_properties(discovery)search_analytics(analytics)inspect_url(indexing)discover_tools(meta — returns available categories + tool descriptions)
When the AI calls discover_tools({ category: "intelligence" }), the server
dynamically registers all intelligence tools and returns their descriptions.
Implementation: server.ts maintains a registeredCategories: Set<string> and
a toolRegistry: Map<string, ToolDefinition>. On discover_tools, it adds
the requested category's tools to the active set and sends a tools/list_changed
notification.
Auth
OAuth 2.0 (for personal use)
- Uses
googleapisOAuth2Client - Reads client ID/secret from env or JSON file
- Opens browser for consent on first run
- Stores refresh token in
~/.gsc-mcp/oauth-token.json - Auto-refreshes access token
Service Account (for CI/automation)
- Reads from
GOOGLE_APPLICATION_CREDENTIALSenv var orGSC_SERVICE_ACCOUNT_KEY(base64) - Uses
google-auth-libraryJWT client - No interactive flow needed
Auth Selection
- If
GSC_OAUTH_CLIENT_FILEorGSC_OAUTH_CLIENT_IDis set → OAuth - If
GOOGLE_APPLICATION_CREDENTIALSorGSC_SERVICE_ACCOUNT_KEYis set → Service Account - Both can coexist; OAuth takes priority
GSC Client (lib/gsc-client.ts)
Wraps the Google Search Console API:
export class GSCClient {
// Properties
listSites(): Promise<Site[]>
getSiteDetails(siteUrl: string): Promise<SiteDetails>
addSite(siteUrl: string): Promise<void>
removeSite(siteUrl: string): Promise<void>
// Analytics
searchAnalytics(siteUrl: string, params: SearchAnalyticsParams): Promise<SearchAnalyticsResponse>
// URL Inspection
inspectUrl(siteUrl: string, inspectionUrl: string): Promise<InspectionResult>
// Sitemaps
listSitemaps(siteUrl: string): Promise<Sitemap[]>
getSitemap(siteUrl: string, feedpath: string): Promise<Sitemap>
submitSitemap(siteUrl: string, feedpath: string): Promise<void>
deleteSitemap(siteUrl: string, feedpath: string): Promise<void>
// Indexing (separate API)
requestIndexing(url: string): Promise<void>
}
Cache Strategy
- Analytics responses: 15 min TTL (data is 2-3 days stale anyway)
- Sitemap list: 5 min TTL
- URL inspection: 30 min TTL
- Properties list: 60 min TTL
- Cache key = tool name + JSON.stringify(sorted args)
Rate Limiting
- Google Search Console API: 1,200 queries/minute
- URL Inspection API: 600 inspections/day per property, 2,000/day total
- Indexing API: 200 requests/day
- Built-in token bucket that queues requests when approaching limits
Environment Variables
# Auth (pick one or both)
GSC_OAUTH_CLIENT_FILE=/path/to/client_secrets.json
GSC_OAUTH_CLIENT_ID=xxx
GSC_OAUTH_CLIENT_SECRET=xxx
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
GSC_SERVICE_ACCOUNT_KEY=base64encodedkey
# Optional
GSC_CACHE_TTL=900 # Default cache TTL in seconds
GSC_DATA_STATE=all # 'all' for fresh data, 'final' for confirmed only
GSC_DEFAULT_ROW_LIMIT=1000 # Default row limit for analytics
GSC_MAX_ROW_LIMIT=25000 # Max row limit
Dependencies
{
"@modelcontextprotocol/sdk": "latest",
"googleapis": "latest",
"google-auth-library": "latest",
"zod": "^3.22",
"zod-to-json-schema": "^3.22",
"open": "^10.0.0"
}
Tool Annotations Reference
| Tool | readOnly | destructive | idempotent | openWorld |
|---|---|---|---|---|
| list_properties | true | false | true | false |
| get_property_details | true | false | true | false |
| verify_ownership | true | false | true | false |
| discover_tools | true | false | true | false |
| search_analytics | true | false | true | false |
| compare_periods | true | false | true | false |
| keyword_cannibalization | true | false | true | false |
| content_decay_detection | true | false | true | false |
| detect_quick_wins | true | false | true | false |
| top_movers | true | false | true | false |
| mobile_vs_desktop | true | false | true | false |
| country_breakdown | true | false | true | false |
| query_clustering | true | false | true | false |
| inspect_url | true | false | true | false |
| batch_inspect_urls | true | false | true | false |
| list_sitemaps | true | false | true | false |
| get_sitemap | true | false | true | false |
| submit_sitemap | false | false | true | false |
| delete_sitemap | false | true | false | false |
| request_indexing | false | false | true | true |
| add_property | false | false | true | false |
| remove_property | false | true | false | false |