bing-webmaster-ai-cli-mcp 0.1.2__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.
Files changed (35) hide show
  1. bing_webmaster_ai_cli_mcp/__init__.py +3 -0
  2. bing_webmaster_ai_cli_mcp/_serialize.py +64 -0
  3. bing_webmaster_ai_cli_mcp/apply.py +197 -0
  4. bing_webmaster_ai_cli_mcp/audit.py +45 -0
  5. bing_webmaster_ai_cli_mcp/auth.py +42 -0
  6. bing_webmaster_ai_cli_mcp/cli.py +920 -0
  7. bing_webmaster_ai_cli_mcp/client.py +230 -0
  8. bing_webmaster_ai_cli_mcp/config.py +142 -0
  9. bing_webmaster_ai_cli_mcp/emptiness.py +82 -0
  10. bing_webmaster_ai_cli_mcp/errors.py +147 -0
  11. bing_webmaster_ai_cli_mcp/http_server.py +77 -0
  12. bing_webmaster_ai_cli_mcp/limits.py +85 -0
  13. bing_webmaster_ai_cli_mcp/mcp_server.py +1153 -0
  14. bing_webmaster_ai_cli_mcp/ops/__init__.py +1 -0
  15. bing_webmaster_ai_cli_mcp/ops/_common.py +83 -0
  16. bing_webmaster_ai_cli_mcp/ops/blocking.py +21 -0
  17. bing_webmaster_ai_cli_mcp/ops/crawl.py +255 -0
  18. bing_webmaster_ai_cli_mcp/ops/geo.py +11 -0
  19. bing_webmaster_ai_cli_mcp/ops/indexnow.py +296 -0
  20. bing_webmaster_ai_cli_mcp/ops/keywords.py +46 -0
  21. bing_webmaster_ai_cli_mcp/ops/links.py +27 -0
  22. bing_webmaster_ai_cli_mcp/ops/params.py +11 -0
  23. bing_webmaster_ai_cli_mcp/ops/sitemaps.py +20 -0
  24. bing_webmaster_ai_cli_mcp/ops/sites.py +54 -0
  25. bing_webmaster_ai_cli_mcp/ops/submission.py +16 -0
  26. bing_webmaster_ai_cli_mcp/ops/traffic.py +55 -0
  27. bing_webmaster_ai_cli_mcp/plans.py +355 -0
  28. bing_webmaster_ai_cli_mcp/render.py +151 -0
  29. bing_webmaster_ai_cli_mcp/urls.py +87 -0
  30. bing_webmaster_ai_cli_mcp/writes.py +361 -0
  31. bing_webmaster_ai_cli_mcp-0.1.2.dist-info/METADATA +436 -0
  32. bing_webmaster_ai_cli_mcp-0.1.2.dist-info/RECORD +35 -0
  33. bing_webmaster_ai_cli_mcp-0.1.2.dist-info/WHEEL +4 -0
  34. bing_webmaster_ai_cli_mcp-0.1.2.dist-info/entry_points.txt +4 -0
  35. bing_webmaster_ai_cli_mcp-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """MCP server and CLI over the Bing Webmaster Tools API."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,64 @@
1
+ """Serialization for Bing's JSON endpoint and its ASP.NET date representation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta, timezone
7
+ from typing import Any
8
+
9
+ from .errors import MalformedResponse
10
+
11
+ _TICKS = re.compile(r"^\\?/Date\((?P<ms>-?\d+)(?:(?P<sign>[+-])(?P<hh>\d{2})(?P<mm>\d{2}))?\)\\?/$")
12
+
13
+
14
+ def unwrap(payload: Any) -> Any:
15
+ if not isinstance(payload, dict) or "d" not in payload:
16
+ raise MalformedResponse(
17
+ "response body has no 'd' envelope",
18
+ suggestion="inspect the status and upstream response contract",
19
+ details={"keys": sorted(payload) if isinstance(payload, dict) else None},
20
+ )
21
+ return payload["d"]
22
+
23
+
24
+ def parse_bing_datetime(value: str) -> datetime:
25
+ match = _TICKS.fullmatch(value)
26
+ if match is None:
27
+ raise MalformedResponse(
28
+ f"not an ASP.NET tick date: {value!r}",
29
+ suggestion="Bing JSON dates must use /Date(milliseconds±hhmm)/",
30
+ )
31
+ try:
32
+ moment = datetime.fromtimestamp(int(match["ms"]) / 1000, tz=UTC)
33
+ if match["sign"] is None:
34
+ return moment
35
+ if int(match["mm"]) >= 60:
36
+ raise ValueError("timezone minutes must be below 60")
37
+ offset = timedelta(hours=int(match["hh"]), minutes=int(match["mm"]))
38
+ if match["sign"] == "-":
39
+ offset = -offset
40
+ return moment.astimezone(timezone(offset))
41
+ except (OSError, OverflowError, ValueError) as exc:
42
+ # A tick count or an offset outside what datetime can represent.
43
+ raise MalformedResponse(f"tick date is out of range: {value!r}") from exc
44
+
45
+
46
+ def encode_bing_datetime(value: datetime) -> str:
47
+ if value.tzinfo is None or value.utcoffset() is None:
48
+ raise ValueError("Bing datetimes must be timezone-aware")
49
+ milliseconds = int(value.timestamp() * 1000)
50
+ offset = value.utcoffset() or timedelta(0)
51
+ sign = "+" if offset >= timedelta(0) else "-"
52
+ total_minutes = abs(int(offset.total_seconds() // 60))
53
+ hours, minutes = divmod(total_minutes, 60)
54
+ return f"/Date({milliseconds}{sign}{hours:02d}{minutes:02d})/"
55
+
56
+
57
+ def decode(value: Any) -> Any:
58
+ if isinstance(value, dict):
59
+ return {key: decode(item) for key, item in value.items() if key != "__type"}
60
+ if isinstance(value, list):
61
+ return [decode(item) for item in value]
62
+ if isinstance(value, str) and _TICKS.fullmatch(value):
63
+ return parse_bing_datetime(value)
64
+ return value
@@ -0,0 +1,197 @@
1
+ """The only module that executes a mutating Bing API request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .audit import AuditLog
10
+ from .client import BingClient
11
+ from .config import Settings
12
+ from .errors import BingWebmasterError, PlanUnknownOutcome
13
+ from .limits import RateLimiter
14
+ from .ops import indexnow
15
+ from .plans import PlanStore, create_write_plan
16
+ from .render import redact, secret_values
17
+ from .writes import prepare_write
18
+
19
+
20
+ async def apply_plan(
21
+ plan_id: str,
22
+ *,
23
+ settings: Settings,
24
+ store: PlanStore,
25
+ client: BingClient | None,
26
+ audit: AuditLog,
27
+ limiter: RateLimiter,
28
+ indexnow_transport: httpx.AsyncBaseTransport | None = None,
29
+ ) -> dict[str, Any]:
30
+ with store.applying(plan_id) as lease:
31
+ plan = lease.plan
32
+ prepared = prepare_write(plan.operation, plan.args)
33
+ if prepared.method != "IndexNow" and client is None:
34
+ raise RuntimeError("a Bing client is required to apply this plan")
35
+ # Re-checked here, not just at plan creation: the denylist may have grown since,
36
+ # and a plan can sit pending for a whole BING_WM_PLAN_TTL_SECONDS.
37
+ try:
38
+ settings.check_site_allowed(plan.site_url)
39
+ except BingWebmasterError as exc:
40
+ audit.record("plan_apply_denied", plan_id=plan_id, error=exc.to_dict())
41
+ raise
42
+
43
+ # Reserved before the request, not counted after it: a ceiling that refuses once
44
+ # the write has already been sent would leave the plan pending and invite a retry.
45
+ reserved_day = limiter.consume(plan.site_url, prepared.cost)
46
+ try:
47
+ if prepared.method == "IndexNow":
48
+ # Fetching the key file sends no submission, so a failure or an interrupt
49
+ # here is an ordinary failure and stays outside the unknown-outcome zone.
50
+ async with httpx.AsyncClient(
51
+ transport=indexnow_transport, timeout=30.0
52
+ ) as preflight:
53
+ await indexnow.verify_key_file(
54
+ preflight,
55
+ prepared.body["host"],
56
+ prepared.body["key"],
57
+ prepared.body["keyLocation"],
58
+ )
59
+ audit.record(
60
+ "plan_apply_attempted",
61
+ plan_id=plan_id,
62
+ operation=plan.operation,
63
+ site=plan.site_url,
64
+ )
65
+ except BingWebmasterError as exc:
66
+ limiter.release(plan.site_url, prepared.cost, day=reserved_day)
67
+ audit.record("plan_apply_failed", plan_id=plan_id, error=exc.to_dict())
68
+ raise
69
+ except BaseException:
70
+ limiter.release(plan.site_url, prepared.cost, day=reserved_day)
71
+ raise
72
+
73
+ try:
74
+ if prepared.method == "IndexNow":
75
+ async with httpx.AsyncClient(
76
+ transport=indexnow_transport, timeout=30.0
77
+ ) as indexnow_http:
78
+ result = await indexnow.submit(
79
+ indexnow_http,
80
+ prepared.body["host"],
81
+ prepared.body["key"],
82
+ prepared.body["urlList"],
83
+ prepared.body["keyLocation"],
84
+ )
85
+ else:
86
+ result = await client.call(
87
+ prepared.method,
88
+ body=prepared.body,
89
+ mutating=True,
90
+ )
91
+ except PlanUnknownOutcome as exc:
92
+ try:
93
+ store.mark_unknown(plan_id)
94
+ except BaseException:
95
+ lease.preserve_lock()
96
+ raise
97
+ audit.record("plan_apply_unknown", plan_id=plan_id, error=exc.to_dict())
98
+ raise
99
+ except BingWebmasterError as exc:
100
+ # Everything reaching here failed before or during dispatch: a lost or
101
+ # ambiguous response is a PlanUnknownOutcome and was handled above.
102
+ limiter.release(plan.site_url, prepared.cost, day=reserved_day)
103
+ audit.record("plan_apply_failed", plan_id=plan_id, error=exc.to_dict())
104
+ raise
105
+ except BaseException as exc:
106
+ # Ctrl-C or any other interruption once dispatch has begun: the lock is
107
+ # released by the context manager, so a plan left pending would invite a
108
+ # retry of a write that may already have been sent.
109
+ try:
110
+ store.mark_unknown(plan_id)
111
+ except BaseException:
112
+ lease.preserve_lock()
113
+ raise
114
+ audit.record(
115
+ "plan_apply_unknown",
116
+ plan_id=plan_id,
117
+ error={"code": "INTERRUPTED", "type": type(exc).__name__},
118
+ )
119
+ raise
120
+
121
+ try:
122
+ store.mark_applied(plan_id)
123
+ except BaseException:
124
+ lease.preserve_lock()
125
+ raise
126
+ audit.record("plan_apply_succeeded", plan_id=plan_id)
127
+ return {
128
+ "applied": True,
129
+ "plan_id": plan_id,
130
+ "operation": plan.operation,
131
+ # A write leaves by this door, and reads leave by ``ops._common.fetch``.
132
+ # Both are redacted, so neither is the way a code Bing echoed back reaches a
133
+ # transcript. The literals we sent are hidden too, not only the named fields.
134
+ "result": redact(result, secret_values(prepared.body)),
135
+ }
136
+
137
+
138
+ async def execute_write(
139
+ operation: str,
140
+ args: dict[str, Any],
141
+ *,
142
+ settings: Settings,
143
+ client: BingClient | None,
144
+ audit: AuditLog,
145
+ limiter: RateLimiter,
146
+ indexnow_transport: httpx.AsyncBaseTransport | None = None,
147
+ ) -> dict[str, Any]:
148
+ """Record a plan and apply it in the same call.
149
+
150
+ The one-step path is not a second write implementation: it still creates the durable
151
+ plan record and then goes through ``apply_plan``, so the denylist, Bing's own
152
+ submission quota, the local daily ceiling, one-shot application and the audit trail
153
+ are exactly the ones the reviewed path uses. What it drops is the human between the
154
+ two steps, which is what ``BING_WM_ALLOW_WRITES`` decides.
155
+ """
156
+ settings.check_writes_allowed()
157
+ store = PlanStore(settings.state_dir, settings.plan_ttl_seconds)
158
+ plan = await create_write_plan(operation, args, settings=settings, client=client)
159
+ try:
160
+ return await apply_plan(
161
+ plan.plan_id,
162
+ settings=settings,
163
+ store=store,
164
+ client=client,
165
+ audit=audit,
166
+ limiter=limiter,
167
+ indexnow_transport=indexnow_transport,
168
+ )
169
+ except BingWebmasterError as exc:
170
+ _close_out(store, plan.plan_id, audit)
171
+ # Whatever went wrong, the caller has to be able to find the record: for an
172
+ # unknown outcome the plan id is the only way to tell which write to check
173
+ # against Bing.
174
+ exc.details = {**(exc.details or {}), "plan_id": plan.plan_id}
175
+ raise
176
+ except BaseException:
177
+ _close_out(store, plan.plan_id, audit)
178
+ raise
179
+
180
+
181
+ def _close_out(store: PlanStore, plan_id: str, audit: AuditLog) -> None:
182
+ """Refuse a one-step plan whose write never happened.
183
+
184
+ Nobody is coming back to it. Left pending it would stay applicable by hand for a
185
+ whole TTL, which would send a change no human ever reviewed. A plan the apply
186
+ boundary already made terminal - applied, or an unknown outcome - keeps exactly the
187
+ state it recorded, and a failure to clean up never masks the original error.
188
+ """
189
+ try:
190
+ if store.get(plan_id).state != "pending":
191
+ return
192
+ store.reject(plan_id)
193
+ audit.record("plan_discarded", plan_id=plan_id, reason="one_step_write_failed")
194
+ except BingWebmasterError:
195
+ return
196
+ except OSError:
197
+ return
@@ -0,0 +1,45 @@
1
+ """Append-only record of attempted and completed writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ try: # pragma: no cover - Windows has no fcntl; O_APPEND still protects each write offset.
12
+ import fcntl
13
+ except ImportError: # pragma: no cover
14
+ fcntl = None # type: ignore[assignment]
15
+
16
+
17
+ class AuditLog:
18
+ def __init__(self, state_dir: Path) -> None:
19
+ directory = Path(state_dir)
20
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
21
+ self._path = directory / "audit.jsonl"
22
+
23
+ def record(self, event: str, **fields: Any) -> None:
24
+ entry = {"ts": datetime.now(UTC).isoformat(), "event": event, **fields}
25
+ line = (json.dumps(entry, ensure_ascii=False, default=str) + "\n").encode()
26
+ descriptor = os.open(self._path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
27
+ try:
28
+ if fcntl is not None:
29
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
30
+ remaining = memoryview(line)
31
+ while remaining:
32
+ written = os.write(descriptor, remaining)
33
+ if written == 0:
34
+ raise OSError("audit write returned zero bytes")
35
+ remaining = remaining[written:]
36
+ os.fsync(descriptor)
37
+ finally:
38
+ if fcntl is not None:
39
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
40
+ os.close(descriptor)
41
+
42
+ def entries(self) -> list[dict[str, Any]]:
43
+ if not self._path.exists():
44
+ return []
45
+ return [json.loads(line) for line in self._path.read_text().splitlines() if line]
@@ -0,0 +1,42 @@
1
+ """Credential application with an interface that can later host OAuth2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from .config import Settings
8
+
9
+
10
+ class AuthProvider(Protocol):
11
+ def apply(self, params: dict[str, Any], headers: dict[str, str]) -> None: ...
12
+
13
+ def secrets(self) -> frozenset[str]: ...
14
+
15
+
16
+ class ApiKeyAuth:
17
+ def __init__(self, api_key: str) -> None:
18
+ self._api_key = api_key
19
+
20
+ def apply(self, params: dict[str, Any], headers: dict[str, str]) -> None:
21
+ # Microsoft documents the API key only as a query-string parameter; the header
22
+ # form it documents (``Authorization: Bearer``) belongs to the separate OAuth2
23
+ # flow, not to a key. So the credential is part of every URL this client builds,
24
+ # and ``secrets`` exists to cover what that implies.
25
+ params["apikey"] = self._api_key
26
+
27
+ def secrets(self) -> frozenset[str]:
28
+ """The literals this provider puts into a request, for the redaction boundary.
29
+
30
+ Whoever applies a credential is the only one that knows what it is. Asking the
31
+ provider instead of reaching into the settings keeps the two in step: a provider
32
+ added later hides its own token by implementing this, and nothing else changes.
33
+ """
34
+ return frozenset({self._api_key})
35
+
36
+
37
+ def build_auth(settings: Settings) -> AuthProvider:
38
+ if settings.api_key is None:
39
+ from .errors import AuthFailed
40
+
41
+ raise AuthFailed("BING_WM_API_KEY is required for Bing Webmaster API calls")
42
+ return ApiKeyAuth(settings.api_key.get_secret_value())