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 +3 -0
- easygsc/allowlist.py +74 -0
- easygsc/api.py +104 -0
- easygsc/auth.py +168 -0
- easygsc/cli.py +144 -0
- easygsc/connect.py +562 -0
- easygsc/prompts.py +22 -0
- easygsc/server.py +255 -0
- easygsc/setup.py +356 -0
- easygsc-0.2.0.dist-info/METADATA +325 -0
- easygsc-0.2.0.dist-info/RECORD +14 -0
- easygsc-0.2.0.dist-info/WHEEL +4 -0
- easygsc-0.2.0.dist-info/entry_points.txt +2 -0
- easygsc-0.2.0.dist-info/licenses/LICENSE +21 -0
easygsc/__init__.py
ADDED
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())
|