Skip to content

feat: add worldbank, fred, usitc, and census-aes MCP packages#6

Merged
Redliana merged 3 commits into
mainfrom
feat/add-trade-mcp-packages
Apr 17, 2026
Merged

feat: add worldbank, fred, usitc, and census-aes MCP packages#6
Redliana merged 3 commits into
mainfrom
feat/add-trade-mcp-packages

Conversation

@Redliana

Copy link
Copy Markdown
Owner

Adds four new MCP server packages extending CMM_Tools with public-sector economic and trade-statistics coverage:

  • worldbank-mcp: World Bank WDI indicators (no key)
  • fred-mcp: FRED time series (FRED_API_KEY)
  • usitc-mcp: USITC DataWeb US trade statistics (USITC_API_TOKEN)
  • census-aes-mcp: Census International Trade exports (CENSUS_API_KEY)

Each package mirrors the uncomtrade-mcp layout (src//, py.typed, hatchling build, server/client/models split).

Registers the four new modules as known-first-party for Ruff isort.

USITC client rewritten against a live bearer token: corrected BASE_URL verified, check_status now probes country/getAllCountries, search_hts now uses commodity/commodityDescriptionLookup, and get_trade_data constructs the real nested SavedQuery envelope for /report2/runReport and flattens the dto.tables[*].rowsNew pivot-table response into TradeRecord rows. Schema discovery notes captured in client.py module docstring; commodity-filter step-4 validation gap tracked as an inline TODO.

Adds four new MCP server packages extending CMM_Tools with public-sector
economic and trade-statistics coverage:

- worldbank-mcp: World Bank WDI indicators (no key)
- fred-mcp: FRED time series (FRED_API_KEY)
- usitc-mcp: USITC DataWeb US trade statistics (USITC_API_TOKEN)
- census-aes-mcp: Census International Trade exports (CENSUS_API_KEY)

Each package mirrors the uncomtrade-mcp layout (src/<snake>/, py.typed,
hatchling build, server/client/models split).

Registers the four new modules as known-first-party for Ruff isort.

USITC client rewritten against a live bearer token: corrected BASE_URL
verified, check_status now probes country/getAllCountries, search_hts
now uses commodity/commodityDescriptionLookup, and get_trade_data
constructs the real nested SavedQuery envelope for /report2/runReport
and flattens the dto.tables[*].rowsNew pivot-table response into
TradeRecord rows. Schema discovery notes captured in client.py module
docstring; commodity-filter step-4 validation gap tracked as an inline
TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ghost

ghost commented Apr 17, 2026

Copy link
Copy Markdown

Rooviewer Clock   See task

Re-reviewed after bc60a08 (parallelized fan-out queries, removed dead aggregate_by param, fixed USITC docstring). The docstring mismatch in get_trade_data is resolved. No new issues found. 3 previously flagged issues remain:

  • worldbank-mcp: search_indicators only searches the first 500 of ~1,600+ WDI indicators due to single-page fetch with client-side filtering
  • usitc-mcp: _DATA_TO_REPORT maps both general_imports and imports_for_consumption to the same GEN_CUSTOMS_VALUE code, making compare_import_types return identical results
  • usitc-mcp: get_trade_data docstring claims a fallback retry on commodity-filter rejection that isn't implemented in the code
  • fred-mcp: get_cmm_dashboard now fires up to 10 concurrent FRED requests (Semaphore(5) x 2 calls each) with no inter-request delay, risking FRED's 120 req/60s rate limit if the curated set grows
Previous reviews

Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues.

Comment on lines +123 to +126
async def search_indicators(self, query: str, per_page: int = 50) -> list[dict[str, Any]]:
"""Search the WDI indicator catalogue by free-text keyword."""
params = {"per_page": min(per_page, 500)}
payload = await self._request("indicator", params=params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

search_indicators fetches at most 500 indicators from /v2/indicator and filters client-side, but WDI has ~1,600+ indicators. Anything outside the first page is unsearchable. Either paginate until all indicators are fetched (respecting the total field in payload[0]), or pass a search query parameter to the WDI API which does support server-side search (e.g., https://api.worldbank.org/v2/indicator?search=mining&format=json).

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +42 to +47
_DATA_TO_REPORT = {
"general_imports": "GEN_CUSTOMS_VALUE",
"imports_for_consumption": "GEN_CUSTOMS_VALUE",
"domestic_exports": "FAS_VALUE",
"total_exports": "FAS_VALUE",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both general_imports and imports_for_consumption map to the same GEN_CUSTOMS_VALUE code. This means compare_import_types in the server will return identical results for both data types, making it functionally useless. imports_for_consumption likely needs a distinct dataToReport value (the module docstring mentions CONS_VAL_YR was observed in the DataWeb UI). If the correct code isn't yet validated, the compare_import_types tool should at minimum warn users that results may be identical until this is resolved.

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +352 to +384
TODO: commodity filtering via ``commoditySelectType='entered'`` is not
yet reliably accepted by the server (it raises a "step 4" validation
error for reasons not yet pinned down). When that happens this method
re-submits the query with ``commoditySelectType='all'`` and lets the
caller filter client-side.
"""
trade_type = _TRADE_TYPE.get(flow.lower())
if not trade_type:
raise USITCAPIError(f"Unknown flow '{flow}'. Use 'import' or 'export'.")
dtr = _DATA_TO_REPORT.get(data_type)
if not dtr:
raise USITCAPIError(
f"Unknown data_type '{data_type}'. Use one of {list(_DATA_TO_REPORT)}."
)

payload = self._build_saved_query(
trade_type=trade_type,
data_to_report=dtr,
years=years,
hts_codes=hts_codes,
country_codes=country_codes,
)

try:
data = await self._post("report2/runReport", payload)
except httpx.HTTPError as e:
raise USITCAPIError(f"runReport failed: {e}") from e

dto = data.get("dto") or {}
errors = dto.get("errors") or []
if errors:
# Server validation (step 2/step 4/etc). Surface one level up.
raise USITCAPIError(f"Query rejected: {'; '.join(errors)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO/docstring here says the method "re-submits the query with commoditySelectType='all' and lets the caller filter client-side" on server rejection, but the actual implementation just raises USITCAPIError on any dto.errors. Either implement the documented fallback or update the docstring to reflect the current behavior (raise on validation errors). As-is, anyone reading the docstring will expect graceful degradation that doesn't exist.

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +177 to +189
await asyncio.sleep(0.1)
records = await client.get_observations(
series_id=series_id,
observation_start=observation_start,
observation_end=observation_end,
limit=5000,
)
latest = None
for r in reversed(records):
if r.value is not None:
latest = {"date": r.date, "value": r.value}
break
metadata = await client.get_series_metadata(series_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 0.1s sleep is only at the start of each loop iteration, but each iteration makes two API calls (get_observations + get_series_metadata). With ~15 curated series that's ~30 requests with an effective average gap of ~0.05s, which can exceed the FRED rate limit of 120 requests per 60 seconds if the category filter is removed or the curated set is expanded. Moving the sleep between each API call (or adding a second sleep before get_series_metadata) would halve the request density.

Suggested change
await asyncio.sleep(0.1)
records = await client.get_observations(
series_id=series_id,
observation_start=observation_start,
observation_end=observation_end,
limit=5000,
)
latest = None
for r in reversed(records):
if r.value is not None:
latest = {"date": r.date, "value": r.value}
break
metadata = await client.get_series_metadata(series_id)
await asyncio.sleep(0.1)
records = await client.get_observations(
series_id=series_id,
observation_start=observation_start,
observation_end=observation_end,
limit=5000,
)
latest = None
for r in reversed(records):
if r.value is not None:
latest = {"date": r.date, "value": r.value}
break
await asyncio.sleep(0.1)
metadata = await client.get_series_metadata(series_id)

Fix it with Roo Code or mention @roomote and request a fix.

nmwashton and others added 2 commits April 17, 2026 13:16
Run ruff format on the four new packages to satisfy the CI
'Format check' step (uv run ruff format --check packages/).

Also replaces an ambiguous Unicode multiplication sign in a
worldbank-mcp docstring to clear RUF002 in the Lint step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review follow-up. Three cleanups:

- usitc-mcp: drop `aggregate_by` param from get_trade_data (client and
  MCP tool). It was advertised as accepting year|month|quarter but
  DataWeb only runs annual; the parameter was plumbed through without
  effect and leaked into response echo. Update the module-level TODO
  to match actual behavior (raises on step-4 instead of silently
  re-submitting).

- usitc-mcp: run compare_import_types' two queries in parallel via
  asyncio.gather — 2x latency win for a tool that always issues both.

- fred-mcp & worldbank-mcp: replace serial CMM-bundle loops (dashboard
  and country profile) with asyncio.Semaphore(5) + gather. Preserves
  rate-limit intent from the prior 0.1s pacing while cutting wall time
  for 10+ series/indicator fan-outs by roughly an order of magnitude.

Also removes a decorative ASCII divider comment in usitc-mcp/client.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Redliana Redliana marked this pull request as draft April 17, 2026 18:29
@Redliana Redliana marked this pull request as ready for review April 17, 2026 18:29
@Redliana Redliana merged commit 43f002c into main Apr 17, 2026
1 of 5 checks passed
@ghost

ghost commented Apr 17, 2026

Copy link
Copy Markdown

Rooviewer Clock   See task

Reviewed at bc60a08. 1 new issue found; 2 previously flagged issues remain unresolved.

  • census-aes-mcp: DEFAULT_EXPORT_VARS and DEFAULT_IMPORT_VARS omit MONTH, so full-year queries (no month filter) return all 12 months of data but every record gets month=0 instead of its actual month
  • worldbank-mcp: search_indicators only searches the first 500 of ~1,600+ WDI indicators due to single-page fetch with client-side filtering
  • usitc-mcp: _DATA_TO_REPORT maps both general_imports and imports_for_consumption to the same GEN_CUSTOMS_VALUE code, making compare_import_types return identical results
  • usitc-mcp: get_trade_data docstring claims a fallback retry on commodity-filter rejection that isn't implemented in the code
Previous reviews

Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues.

Comment on lines +37 to +48
DEFAULT_EXPORT_VARS = [
"E_COMMODITY",
"E_COMMODITY_LDESC",
"ALL_VAL_MO",
"ALL_VAL_YR",
"CTY_CODE",
"CTY_NAME",
"UNIT_QY1",
"QTY_1_MO",
"UNIT_QY2",
"QTY_2_MO",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_EXPORT_VARS (and DEFAULT_IMPORT_VARS) do not include MONTH. When month is None, MONTH is neither in the get list nor added as a predicate filter, so the Census API won't return a MONTH column in the response. row.get("MONTH") then returns None, and every record gets month=0 via month_fallback. This means full-year queries (no month filter) return data for all 12 months but every record loses its actual month -- they all read month=0. Adding "MONTH" to both DEFAULT_EXPORT_VARS and DEFAULT_IMPORT_VARS (or appending it to the get param explicitly) would fix this.

Suggested change
DEFAULT_EXPORT_VARS = [
"E_COMMODITY",
"E_COMMODITY_LDESC",
"ALL_VAL_MO",
"ALL_VAL_YR",
"CTY_CODE",
"CTY_NAME",
"UNIT_QY1",
"QTY_1_MO",
"UNIT_QY2",
"QTY_2_MO",
]
DEFAULT_EXPORT_VARS = [
"E_COMMODITY",
"E_COMMODITY_LDESC",
"ALL_VAL_MO",
"ALL_VAL_YR",
"CTY_CODE",
"CTY_NAME",
"UNIT_QY1",
"QTY_1_MO",
"UNIT_QY2",
"QTY_2_MO",
"MONTH",
]

Fix it with Roo Code or mention @roomote and request a fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants