easygsc 0.2.0__py3-none-any.whl

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.
easygsc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """easygsc: minimal, transparent, read-only MCP server for Google Search Console."""
2
+
3
+ __version__ = "0.2.0"
easygsc/allowlist.py ADDED
@@ -0,0 +1,74 @@
1
+ """Transport-level network allowlist: the single chokepoint for all outbound HTTP.
2
+
3
+ The guard lives at the ``requests`` transport layer (``Session.send``) because
4
+ several independent sessions exist: our API calls, ``google-auth``'s token
5
+ refresh session, and ``InstalledAppFlow``'s ``OAuth2Session``. Redirects are
6
+ covered: ``requests`` re-calls ``Session.send`` for every hop (its HTTPAdapter
7
+ sends with ``redirect=False``, so urllib3 never follows redirects internally)
8
+ and the final response URL is checked as defense in depth.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import types
14
+ from urllib.parse import urlparse
15
+
16
+ import requests
17
+ from google.auth.transport.requests import AuthorizedSession as _AuthorizedSession
18
+ from google.auth.transport.requests import Request as _GoogleAuthRequest
19
+
20
+ ALLOWED_HOSTS = frozenset(
21
+ {
22
+ "www.googleapis.com", # webmasters/v3 REST API
23
+ "searchconsole.googleapis.com", # urlInspection/index:inspect
24
+ "oauth2.googleapis.com", # token refresh and code exchange
25
+ "accounts.google.com", # OAuth authorization flow
26
+ }
27
+ )
28
+
29
+
30
+ class DisallowedHostError(Exception):
31
+ """Raised when an outgoing request targets a host outside ALLOWED_HOSTS."""
32
+
33
+
34
+ def check_allowed(url: str) -> str:
35
+ """Raise DisallowedHostError unless *url*'s host is allowed. Returns the host."""
36
+ host = (urlparse(url).hostname or "").lower().rstrip(".")
37
+ if host not in ALLOWED_HOSTS:
38
+ raise DisallowedHostError(
39
+ f"Blocked request to non-allowed host {host or '(none)'!r}: {url}. "
40
+ f"easygsc only talks to: {', '.join(sorted(ALLOWED_HOSTS))}."
41
+ )
42
+ return host
43
+
44
+
45
+ class GuardedSessionMixin:
46
+ """Mixin overriding send() to enforce the allowlist, redirects included."""
47
+
48
+ def send(self, request, **kwargs): # type: ignore[override]
49
+ check_allowed(request.url)
50
+ # Explicit Session.send: works for real subclasses (MRO) and for
51
+ # instances guarded via attach_guard (MethodType), where zero-arg
52
+ # super() cannot resolve. Neither AuthorizedSession nor OAuth2Session
53
+ # overrides send(), so nothing is bypassed.
54
+ response = requests.Session.send(self, request, **kwargs)
55
+ check_allowed(response.url) # defense in depth against below-send redirects
56
+ return response
57
+
58
+
59
+ class GuardedSession(GuardedSessionMixin, requests.Session):
60
+ """A requests.Session that may only talk to ALLOWED_HOSTS."""
61
+
62
+
63
+ class GuardedAuthorizedSession(GuardedSessionMixin, _AuthorizedSession):
64
+ """An AuthorizedSession whose own traffic and refresh channel are guarded."""
65
+
66
+ def __init__(self, credentials, **kwargs):
67
+ super().__init__(
68
+ credentials, auth_request=_GoogleAuthRequest(session=GuardedSession()), **kwargs
69
+ )
70
+
71
+
72
+ def attach_guard(oauth2session) -> None:
73
+ """Attach the send() guard to an existing OAuth2Session instance."""
74
+ oauth2session.send = types.MethodType(GuardedSessionMixin.send, oauth2session)
easygsc/api.py ADDED
@@ -0,0 +1,104 @@
1
+ """Thin REST wrapper around the four Search Console endpoints.
2
+
3
+ Nothing more: each function maps one endpoint, URL-encodes the siteUrl, and
4
+ maps errors. All HTTP goes through a guarded AuthorizedSession from auth.
5
+ Responses are returned exactly as Google sent them; formatting lives in
6
+ server.py.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from urllib.parse import quote
13
+
14
+ import requests
15
+
16
+ from .auth import make_authorized_session as make_session
17
+
18
+ SITES_URL = "https://www.googleapis.com/webmasters/v3/sites"
19
+ INSPECT_URL = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
20
+
21
+
22
+ class ApiError(RuntimeError):
23
+ """Google API error with status, Google's message, and a hint (or None)."""
24
+
25
+ def __init__(self, status: int, message: str, hint: str | None = None):
26
+ self.status = status
27
+ self.message = message
28
+ self.hint = hint
29
+ super().__init__(f"Google API error {status}: {message}")
30
+
31
+
32
+ def _hint(status: int, reason: str | None) -> str | None:
33
+ if status == 403:
34
+ return (
35
+ "Check the property format: 'sc-domain:example.com' for domain "
36
+ "properties vs 'https://example.com/' (with trailing slash) for "
37
+ "URL-prefix properties, and confirm the account has access."
38
+ )
39
+ if status == 401 or reason == "invalid_grant":
40
+ return "The token is expired or revoked; run 'easygsc auth' again."
41
+ if status == 429 or reason in ("rateLimitExceeded", "quotaExceeded"):
42
+ return (
43
+ "Quota exceeded. Search Console enforces per-day/per-property "
44
+ "quotas; wait or reduce request volume. URL Inspection is limited "
45
+ "to 2,000 queries/day per property."
46
+ )
47
+ return None
48
+
49
+
50
+ def _request(session, method: str, url: str, **kwargs) -> dict:
51
+ try:
52
+ response = session.request(method, url, timeout=60, **kwargs)
53
+ except requests.exceptions.RequestException as exc:
54
+ raise ApiError(0, f"Request failed: {exc}") from exc
55
+ if response.status_code >= 400:
56
+ message = response.text
57
+ reason = None
58
+ try:
59
+ err = response.json().get("error", {})
60
+ message = err.get("message") or message
61
+ reasons = err.get("errors") or [{}]
62
+ reason = reasons[0].get("reason")
63
+ except (ValueError, AttributeError, json.JSONDecodeError):
64
+ pass
65
+ raise ApiError(response.status_code, message, _hint(response.status_code, reason))
66
+ try:
67
+ return response.json()
68
+ except ValueError as exc:
69
+ raise ApiError(response.status_code, f"Non-JSON response: {response.text[:200]}") from exc
70
+
71
+
72
+ def _site_url(site_url: str) -> str:
73
+ return quote(site_url, safe="")
74
+
75
+
76
+ def _session():
77
+ return make_session()
78
+
79
+
80
+ def list_sites(session=None) -> dict:
81
+ """GET /webmasters/v3/sites — properties the account can read."""
82
+ session = session or _session()
83
+ return _request(session, "GET", SITES_URL)
84
+
85
+
86
+ def search_analytics(site_url: str, body: dict, session=None) -> dict:
87
+ """POST /sites/{siteUrl}/searchAnalytics/query with the caller-built body."""
88
+ session = session or _session()
89
+ url = f"{SITES_URL}/{_site_url(site_url)}/searchAnalytics/query"
90
+ return _request(session, "POST", url, json=body)
91
+
92
+
93
+ def list_sitemaps(site_url: str, session=None) -> dict:
94
+ """GET /sites/{siteUrl}/sitemaps."""
95
+ session = session or _session()
96
+ url = f"{SITES_URL}/{_site_url(site_url)}/sitemaps"
97
+ return _request(session, "GET", url)
98
+
99
+
100
+ def inspect_url(site_url: str, url: str, session=None) -> dict:
101
+ """POST /v1/urlInspection/index:inspect (index:inspect method)."""
102
+ session = session or _session()
103
+ body = {"inspectionUrl": url, "siteUrl": site_url, "languageCode": "en-US"}
104
+ return _request(session, "POST", INSPECT_URL, json=body)
easygsc/auth.py ADDED
@@ -0,0 +1,168 @@
1
+ """Credential resolution, the OAuth flow, and token storage.
2
+
3
+ Resolution order (documented in README, printed by ``easygsc doctor``):
4
+ 1. service account file (``EASYGSC_SERVICE_ACCOUNT_FILE``)
5
+ 2. refresh token from the environment (``EASYGSC_REFRESH_TOKEN`` plus the
6
+ OAuth client from env vars or ``client_secret.json``), for stateless setups
7
+ 3. saved OAuth token (``~/.config/easygsc/token.json``)
8
+ 4. otherwise run ``easygsc auth``
9
+
10
+ Nothing here logs or returns secrets; the refresh token is printed exactly
11
+ once, by ``run_oauth_flow``, because the user asked for it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ from google.auth.transport.requests import Request as GoogleAuthRequest
22
+ from google.oauth2 import credentials as oauth2_credentials
23
+ from google.oauth2 import service_account
24
+ from google_auth_oauthlib.flow import InstalledAppFlow
25
+
26
+ from .allowlist import GuardedSession, attach_guard
27
+
28
+ SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
29
+
30
+ # Same location on every OS, per the project spec.
31
+ CONFIG_DIR = Path.home() / ".config" / "easygsc"
32
+ TOKEN_PATH = CONFIG_DIR / "token.json"
33
+ CLIENT_SECRET_PATH = CONFIG_DIR / "client_secret.json"
34
+
35
+ GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
36
+ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
37
+
38
+
39
+ class AuthError(RuntimeError):
40
+ """Raised when no usable credentials can be resolved."""
41
+
42
+
43
+ def client_config() -> dict:
44
+ """Build the 'installed' client config from env vars or client_secret.json."""
45
+ client_id = os.environ.get("EASYGSC_CLIENT_ID")
46
+ client_secret = os.environ.get("EASYGSC_CLIENT_SECRET")
47
+ if client_id and client_secret:
48
+ return {
49
+ "installed": {
50
+ "client_id": client_id,
51
+ "client_secret": client_secret,
52
+ "auth_uri": GOOGLE_AUTH_URI,
53
+ "token_uri": GOOGLE_TOKEN_URI,
54
+ "redirect_uris": ["http://localhost"],
55
+ }
56
+ }
57
+ if CLIENT_SECRET_PATH.is_file():
58
+ with open(CLIENT_SECRET_PATH, encoding="utf-8") as f:
59
+ return json.load(f)
60
+ raise AuthError(
61
+ "No OAuth client configured. Provide EASYGSC_CLIENT_ID and "
62
+ "EASYGSC_CLIENT_SECRET, or place a Desktop-app client_secret.json at "
63
+ f"{CLIENT_SECRET_PATH}, then run 'easygsc auth'."
64
+ )
65
+
66
+
67
+ def resolve_credentials():
68
+ """Resolve credentials in the documented order. Raises AuthError if none."""
69
+ sa_file = os.environ.get("EASYGSC_SERVICE_ACCOUNT_FILE")
70
+ if sa_file:
71
+ creds = service_account.Credentials.from_service_account_file(sa_file, scopes=SCOPES)
72
+ return creds
73
+ refresh_token = os.environ.get("EASYGSC_REFRESH_TOKEN")
74
+ if refresh_token:
75
+ client = next(iter(client_config().values()))
76
+ return oauth2_credentials.Credentials(
77
+ token=None,
78
+ refresh_token=refresh_token,
79
+ token_uri=client.get("token_uri", GOOGLE_TOKEN_URI),
80
+ client_id=client["client_id"],
81
+ client_secret=client["client_secret"],
82
+ scopes=SCOPES,
83
+ )
84
+ if TOKEN_PATH.is_file():
85
+ return oauth2_credentials.Credentials.from_authorized_user_file(
86
+ str(TOKEN_PATH), scopes=SCOPES
87
+ )
88
+ raise AuthError(
89
+ "No credentials found. Run 'easygsc auth' first, or set "
90
+ "EASYGSC_SERVICE_ACCOUNT_FILE for service-account setups. "
91
+ f"Expected token at {TOKEN_PATH}."
92
+ )
93
+
94
+
95
+ def token_info() -> dict:
96
+ """Safe description of the stored token for doctor: no secrets."""
97
+ if not TOKEN_PATH.is_file():
98
+ return {"present": False}
99
+ try:
100
+ data = json.loads(TOKEN_PATH.read_text(encoding="utf-8"))
101
+ except (OSError, json.JSONDecodeError):
102
+ return {"present": True, "readable": False}
103
+ return {
104
+ "present": True,
105
+ "readable": True,
106
+ "expiry": data.get("expiry"),
107
+ "has_refresh_token": bool(data.get("refresh_token")),
108
+ "scopes": data.get("scopes"),
109
+ "account": data.get("account"),
110
+ }
111
+
112
+
113
+ def save_token(credentials) -> Path:
114
+ """Write the credentials JSON atomically with 0600 (best effort on Windows)."""
115
+ payload = credentials.to_json()
116
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
117
+ fd, tmp_name = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".token-", suffix=".tmp")
118
+ try:
119
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
120
+ f.write(payload)
121
+ f.flush()
122
+ os.fsync(f.fileno())
123
+ try:
124
+ os.chmod(tmp_name, 0o600)
125
+ except OSError:
126
+ pass # best effort on platforms without POSIX permissions
127
+ os.replace(tmp_name, TOKEN_PATH)
128
+ except BaseException:
129
+ try:
130
+ os.unlink(tmp_name)
131
+ except OSError:
132
+ pass
133
+ raise
134
+ return TOKEN_PATH
135
+
136
+
137
+ def run_oauth_flow(no_browser: bool = False, *, print_refresh_token: bool = True):
138
+ """Run the installed-app OAuth flow and persist the token.
139
+
140
+ ``--no-browser`` still uses the loopback redirect flow; it only skips
141
+ opening the browser window automatically (the URL is printed instead).
142
+ ``print_refresh_token=False`` keeps the token out of stdout for callers
143
+ such as ``easygsc setup``; ``easygsc auth`` keeps the default.
144
+ """
145
+ flow = InstalledAppFlow.from_client_config(client_config(), scopes=SCOPES)
146
+ # The code exchange goes through the flow's own OAuth2Session: guard it.
147
+ attach_guard(flow.oauth2session)
148
+ credentials = flow.run_local_server(port=0, open_browser=not no_browser)
149
+ save_token(credentials)
150
+ # The user explicitly asked for the refresh token (stateless setups);
151
+ # this is the only place it is ever printed.
152
+ if print_refresh_token:
153
+ print(f"EASYGSC_REFRESH_TOKEN={credentials.refresh_token}")
154
+ return credentials
155
+
156
+
157
+ def make_authorized_session():
158
+ """AuthorizedSession with guarded own traffic and guarded refresh channel."""
159
+ from .allowlist import GuardedAuthorizedSession
160
+
161
+ return GuardedAuthorizedSession(resolve_credentials())
162
+
163
+
164
+ def refresh_credentials_if_expired(credentials) -> None:
165
+ """Refresh credentials through the guarded session if needed."""
166
+
167
+ if not credentials.valid:
168
+ credentials.refresh(GoogleAuthRequest(session=GuardedSession()))
easygsc/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """CLI: ``easygsc``/``easygsc serve`` runs MCP over stdio; ``auth`` runs the
2
+ OAuth flow; ``doctor`` reports auth state and exits 1 when auth fails; ``setup``
3
+ runs the guided wizard; ``connect`` adds easygsc to the user's AI apps. Logs go to
4
+ stderr (WARNING by default); secrets are never printed; doctor output goes to stdout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import sys
12
+
13
+ from . import __version__, api, auth, connect, server
14
+
15
+
16
+ def _setup_logging() -> None:
17
+ logging.basicConfig(
18
+ stream=sys.stderr,
19
+ level=logging.WARNING,
20
+ format="%(name)s %(levelname)s: %(message)s",
21
+ force=True, # a fresh CLI process has no handlers; also rebinds in tests
22
+ )
23
+
24
+
25
+ def _auth_mode() -> str:
26
+ import os
27
+
28
+ if os.environ.get("EASYGSC_SERVICE_ACCOUNT_FILE"):
29
+ return "service-account"
30
+ if os.environ.get("EASYGSC_REFRESH_TOKEN"):
31
+ return "oauth-env"
32
+ if auth.TOKEN_PATH.is_file():
33
+ return "oauth-token"
34
+ return "none"
35
+
36
+
37
+ def _doctor() -> int:
38
+ mode = _auth_mode()
39
+ lines = [
40
+ f"easygsc version: {__version__}",
41
+ f"auth mode: {mode}",
42
+ f"config dir: {auth.CONFIG_DIR}",
43
+ f"token file: {auth.TOKEN_PATH}",
44
+ f"client secret file: {auth.CLIENT_SECRET_PATH}",
45
+ ]
46
+ if mode == "oauth-token":
47
+ info = auth.token_info()
48
+ lines.append(f"token expiry: {info.get('expiry', 'unknown')}")
49
+ lines.append(f"token has refresh_token: {info.get('has_refresh_token', False)}")
50
+ elif mode == "oauth-env":
51
+ lines.append("token expiry: n/a (refresh token from EASYGSC_REFRESH_TOKEN)")
52
+ elif mode == "service-account":
53
+ lines.append("token expiry: n/a (service account keys do not expire)")
54
+ else:
55
+ lines.append("token expiry: n/a (not authenticated)")
56
+
57
+ if mode == "none":
58
+ lines += [
59
+ "readable properties: unknown (not authenticated)",
60
+ "auth check: FAILED - run 'easygsc auth' or set EASYGSC_SERVICE_ACCOUNT_FILE",
61
+ ]
62
+ print("\n".join(lines))
63
+ return 1
64
+
65
+ try:
66
+ count = len(api.list_sites().get("siteEntry", []))
67
+ lines += [f"readable properties: {count}", "auth check: OK"]
68
+ print("\n".join(lines))
69
+ return 0
70
+ except api.ApiError as err:
71
+ lines += ["readable properties: unknown (API error)", f"auth check: FAILED - {err}"]
72
+ print("\n".join(lines))
73
+ return 1
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ _setup_logging()
78
+ parser = argparse.ArgumentParser(
79
+ prog="easygsc",
80
+ description=(
81
+ "Minimal, transparent, read-only MCP server for Google Search "
82
+ "Console. With no arguments, serves MCP over stdio."
83
+ ),
84
+ )
85
+ sub = parser.add_subparsers(dest="command")
86
+
87
+ auth_parser = sub.add_parser("auth", help="run the OAuth flow and store the token")
88
+ auth_parser.add_argument(
89
+ "--no-browser",
90
+ action="store_true",
91
+ help="do not open a browser window; print the URL to open instead",
92
+ )
93
+
94
+ sub.add_parser("doctor", help="show auth mode, token expiry, config paths, version")
95
+ sub.add_parser("serve", help="run the MCP server over stdio (default)")
96
+
97
+ setup_parser = sub.add_parser(
98
+ "setup", help="guided setup: Google Cloud steps, login, connect your AI apps"
99
+ )
100
+ setup_parser.add_argument(
101
+ "--no-browser",
102
+ action="store_true",
103
+ help="print URLs instead of opening a browser window",
104
+ )
105
+
106
+ sub.add_parser(
107
+ "connect", help="add easygsc to your AI apps (Claude, Cursor, VS Code, and more)"
108
+ )
109
+
110
+ args = parser.parse_args(argv)
111
+
112
+ if args.command == "connect":
113
+ if _auth_mode() == "none":
114
+ print("Note: you are not logged in yet. Run 'easygsc setup' first.")
115
+ try:
116
+ return connect.run()
117
+ except KeyboardInterrupt:
118
+ print("\nconnect interrupted: run 'easygsc connect' again.", file=sys.stderr)
119
+ return 130
120
+
121
+ if args.command == "setup":
122
+ from . import setup # lazy: setup imports _doctor from this module
123
+
124
+ try:
125
+ return setup.run(no_browser=args.no_browser)
126
+ except KeyboardInterrupt:
127
+ print("\nsetup interrupted: run 'easygsc setup' again when ready.", file=sys.stderr)
128
+ return 130
129
+ if args.command == "auth":
130
+ try:
131
+ auth.run_oauth_flow(no_browser=args.no_browser)
132
+ return 0
133
+ except auth.AuthError as exc:
134
+ print(f"auth failed: {exc}", file=sys.stderr)
135
+ return 1
136
+ if args.command == "doctor":
137
+ return _doctor()
138
+ # No args or explicit serve: MCP over stdio.
139
+ server.run()
140
+ return 0
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())