The shared runtime handles transport, errors, caching and parsing helpers, so a new connector is mostly fetch-and-parse logic. Budget most of your time for step 1 anyway — it decides whether the rest is worth doing.
Anti-bot posture, not API quality, determines feasibility. Megamarket has the cleanest API of any source evaluated for this project and is unusable; Wildberries has a messy one and works perfectly.
Answer these five questions with real HTTP responses, not assumptions:
- Does anything answer anonymously? Try the internal endpoints the web client
uses, with a realistic Chrome
User-AgentandAccept-Language: ru-RU. - What does the block look like? A 403 is different from a redirect loop is
different from a JS proof-of-work. Loops (
?...&__rr=1) mean IP reputation; proof-of-work means you need a real browser. - Are all three data families reachable? Search, product detail, reviews. A source with detail but no search cannot be discovered through. Lamoda is the case to study: its GraphQL endpoint answers card queries over plain HTTPS but offers no text search, so search had to go through the CDP tier. That split is why the connector carries two transports instead of one.
- Does the "search" actually search? Send a distinctive query and read the results. Detsky Mir's API accepts text filters and ignores them, returning its entire 300k catalog. See ANTI_BOT.md.
- What happens under load? Five to ten rapid requests. Note the rate limit and whether a captcha appears.
Record the answers, including the negatives. A precise "this returns 403 even with full browser headers" is worth more than an optimistic guess.
Tier 1 (anonymous HTTP) if plain HTTPS works. Use
mcp_core.transport.http_tier.
Tier 2 (authenticated Chrome) if the source rejects datacenter fingerprints.
Use mcp_core.transport.chrome_cdp and add a host allowlist — it runs inside a real
session, so a crafted input must never reach a personal endpoint. See
CDP_SETUP.md.
Neither if it needs a reversed binary protocol or per-request proof-of-work.
Document the refusal in ANTI_BOT.md and stop. Two of six candidates ended here.
packages/<name>-connector/
├── pyproject.toml
├── src/<name>_connector/
│ ├── __init__.py
│ ├── __main__.py console-script entry point
│ ├── models_output.py typed responses
│ ├── settings.py env-prefixed config
│ ├── server.py FastMCP tools
│ └── py.typed PEP 561 marker — do not omit
└── tests/
├── conftest.py needed: test basenames collide across packages
└── test_server.py
py.typed matters more than it looks: without it, mypy treats every cross-package
import as Any and reports phantom errors elsewhere in the tree.
pyproject.toml:
[project]
name = "<name>-connector"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["mcp-core", "fastmcp>=3.4.0", "httpx>=0.27", "pydantic>=2.6", "pydantic-settings>=2.2"]
[project.scripts]
<name>-mcp = "<name>_connector.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/<name>_connector"]
[tool.uv.sources]
mcp-core = { workspace = true }Then register it in the workspace root pyproject.toml: add to dependencies,
[tool.uv.sources], and [tool.pytest.ini_options] testpaths.
Four rules carry most of the quality:
Validate inputs by shape, don't escape them. A product id that lands in a URL
path is checked with .isdigit(); a category alias is matched against a slug
pattern. Rejecting a malformed value is easier to verify than escaping it correctly.
A missing value is None, never 0. Use coerce_price/coerce_int from
mcp_core.resilience. A zero price ranks a dead listing as the cheapest option —
the exact bug these helpers exist to prevent.
Write field descriptions for a reader who cannot see the API. They are what an LLM uses to decide whether your tool answers the question. Explain semantics, not names: "counts star ratings, not written reviews, which is usually far smaller".
Warn instead of hiding. Partial data, a fallback path, an empty result — all
belong in meta.warnings with healthy: false. Silence reads as success.
Every tool needs ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True) and an ## Error Format note in its
docstring.
async def <name>_selfcheck(ctx=None) -> SelfcheckResponse:
# success — every family answered in the expected shape
# drift_detected — reachable but unparseable → code change needed
# inconclusive — transport/geo/captcha → says nothing about parsersChain the probes where you can: have the search probe supply a live id for the card probe, so the canary never depends on a hardcoded SKU that may be delisted.
Do not write your own DOM helpers. mcp_core.dom exports JS_HELPERS; splice it
into your extractor and you inherit four things that were each learned the hard
way on a live site:
from mcp_core.dom import JS_HELPERS, prices_from_tile, title_from_tile
_SEARCH_EXTRACT_TEMPLATE = """
() => {
//__SHARED_HELPERS__
...
}
"""
_SEARCH_EXTRACT_JS = _SEARCH_EXTRACT_TEMPLATE.replace("//__SHARED_HELPERS__", JS_HELPERS)| helper | what it saves you from |
|---|---|
tileRootFor(anchor, idRe) |
closest() tests the element itself first, so an image link whose class contains "product" becomes the tile — and the tile reads as empty |
priceTextsIn(root) |
splits numbers by whether a currency glyph is attached; a bare number never becomes the price |
cleanText / cleanTextWithout |
textContent, not innerText — the latter depends on layout and is unavailable in the test harness |
DECOY_RE |
instalments, bonuses, discount badges, delivery counts and ratings, generated from one Python list |
The extractor must not do arithmetic. Return the display strings and let
prices_from_tile decide: coerce_price already understands non-breaking
spaces, a missing glyph and comma decimals, and refuses an ambiguous
multi-number blob instead of inventing a value.
The one rule to carry: the price is the number attached to a currency glyph. A tile also shows "от 5 751 ₽/ мес.", "- 10%", "в 1356 пунктов" and a rating. A parser that takes the smallest number reports the monthly instalment, and that answer validates, looks right, and is wrong.
Never let a test touch the network. Monkeypatch the fetch layer and assert the contract an agent sees — which error code, which warning, which fields.
For HTML/SSR sources, capture a real page and trim it rather than inventing markup: preserve the exact nesting so upstream structural changes still surface. The Yandex fixtures went from ~2 MB to ~60 KB this way.
Test the extractor against that markup, not around it. Mocking the render
call and feeding a pre-parsed dict leaves the parser itself uncovered — that is
exactly where the DNS and Citilink bugs lived while 707 tests stayed green.
mcp_core.domtest runs your real extractor over a fixture in jsdom:
from mcp_core.domtest import JsdomUnavailable, run_extractor
def _extract(js_source):
try:
return run_extractor(js_source, FIXTURE, page_url="https://example.ru/search/")
except JsdomUnavailable as exc:
pytest.skip(str(exc))jsdom is a developer tool, not a dependency: without it the DOM half skips and the pure-Python price-selection assertions still run. Write both halves.
Record in the fixture header what the page displayed when you captured it — price, old price, availability. That turns the fixture from a blob into evidence someone can re-check.
Mark anything that needs the network @pytest.mark.live and anything needing a
browser @pytest.mark.cdp; CI excludes both.
skills/<name>-connector/SKILL.md— required, and enforced:packages/marketplace-connector/tests/test_skills_parity.pyfails the build if a connector has no skill, if the skill omits a tool the server exposes, or if it names a tool that does not exist. Cover when to use it, the workflow patterns, and the gotchas from step 1 — and be explicit about what the source cannot do and which of its answers need a second look. "Run the selfcheck" is not a verification story: a green selfcheck proves the transport answered, not that the parser understood it.compare-connector— add a_search_<name>adapter and an entry in_SEARCH_IMPLS, but only if the source has a working text search. If it does not, leave it out ofSEARCHABLEand say why in a comment.- CI — bump the expected tool count in
.github/workflows/ci.ymland add the console script to the smoke step. - README and CHANGELOG — the tool table and the release notes.
uv sync --all-packages
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv run pytest -q -m "not live and not cdp"
uv run python scripts/check_no_print.pyThen run your selfcheck live and confirm the tool inventory:
uv run python -c "
import asyncio
from <name>_connector.server import mcp
print(asyncio.run(mcp.list_tools()))
"If a capability does not exist upstream, do not ship a tool that pretends it does.
Detsky Mir has no text search. A detmir_search tool was written, tested against
live data, and deleted — for the query "лего" it returned nappies, dishwashing
liquid and a collagen supplement, each with a correct price and rating. That
plausibility is what made it dangerous: an error is diagnosable, a confident wrong
answer is not.
Document the absence in the tool descriptions so an agent stops looking for a tool that should not exist.